Compare commits

...

531 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
dadaloop82 49e5319f4c merge: scale 2g noise filter from develop 2026-05-14 14:49:58 +00:00
dadaloop82 3ebe551b9e fix(scale): ignore weight variations < 2g everywhere (stability, live box, autofill) 2026-05-14 14:49:56 +00:00
dadaloop82 0e1eccfe33 merge: kiosk update check fixes from develop 2026-05-14 14:47:50 +00:00
dadaloop82 4624811707 fix(kiosk): periodic update check every 30min + persist pending update across restarts 2026-05-14 14:47:47 +00:00
dadaloop82 3607ebf1d7 merge: move modal fixes from develop 2026-05-14 12:22:47 +00:00
dadaloop82 8bb6c01b7d fix: move modal countdown stops on touch; vacuum btn no longer triggers native settings 2026-05-14 12:22:45 +00:00
dadaloop82 b1a882f92d merge: kiosk crash detection from develop 2026-05-14 11:47:12 +00:00
dadaloop82 1b7b271b43 fix(kiosk): detect ANR/OOM/native crashes on restart via ApplicationExitInfo + dirty sentinel 2026-05-14 11:47:05 +00:00
dadaloop82 8d22b36b0f merge: v1.7.12 from develop 2026-05-13 11:40:27 +00:00
dadaloop82 2d70e7a688 chore: release v1.7.12 — banner aperti, fix ricette pz, fix usa-tutto 2026-05-13 11:40:05 +00:00
dadaloop82 8da627f8a6 Merge branches 'main' and 'main' of github-evershelf:dadaloop82/EverShelf 2026-05-12 15:31:40 +00:00
dadaloop82 d3492a38f7 release: v1.7.11 — scan redesign, AI OCR, anomaly fixes 2026-05-12 15:31:23 +00:00
dadaloop82 34e13075ea chore: release v1.7.11 — scan redesign, AI OCR, anomaly fixes 2026-05-12 15:31:07 +00:00
dadaloop82 bba0f4715a fix: remove datalist suggestions from quick-name input (scan page) 2026-05-12 15:29:40 +00:00
github-actions[bot] e3ae2d10b2 chore: auto-merge develop → main
Triggered by: 696a9c6 feat: scan page redesign — fixed 2x zoom, torch, camera flip, tabs, recents, AI number OCR
2026-05-12 14:57:53 +00:00
dadaloop82 696a9c6d11 feat: scan page redesign — fixed 2x zoom, torch, camera flip, tabs, recents, AI number OCR
- Always-on 2x hardware zoom (CSS scale fallback)
- Torch button with toggle + visual feedback
- Camera flip (front/back) with settings persistence
- 3-tab input panel: Barcode / Name / AI
- Recent products chips (last 6 scanned, from localStorage)
- Live barcode code overlay during partial detection
- Confirm overlay (checkmark + name) on successful scan
- AI number OCR (Gemini reads barcode digits from image, shown after 4s)
- Guide corners frame in viewport
- PHP: gemini_number_ocr action + rate-limited
- Translations: new scan.* keys in it/en/de
2026-05-12 14:55:14 +00:00
github-actions[bot] 35de42657a chore: auto-merge develop → main
Triggered by: 27ba417 fix: consumption predictions require >=5 txns, 7-day spread, and >=15% predicted consumption ratio
2026-05-12 14:33:12 +00:00
dadaloop82 27ba41700f fix: consumption predictions require >=5 txns, 7-day spread, and >=15% predicted consumption ratio 2026-05-12 14:31:24 +00:00
github-actions[bot] 2f040ee041 chore: auto-merge develop → main
Triggered by: 2c34387 fix: remove 'untracked' anomaly direction — incomplete purchase history is normal, not an anomaly
2026-05-12 05:53:53 +00:00
dadaloop82 2c34387592 fix: remove 'untracked' anomaly direction — incomplete purchase history is normal, not an anomaly 2026-05-12 05:52:09 +00:00
dadaloop82 3e65c7ee43 Merge branch 'main' of github-evershelf:dadaloop82/EverShelf 2026-05-11 17:40:22 +00:00
dadaloop82 49ac0b4ce8 release: v1.7.10 — banner fixes, opened tracking, anomaly detection improvements 2026-05-11 17:40:13 +00:00
dadaloop82 9e2722f7a4 chore: update CHANGELOG and README for v1.7.10 2026-05-11 17:40:01 +00:00
github-actions[bot] c47eba3ddb chore: auto-merge develop → main
Triggered by: d056a6a fix: expired section hides items with quantity=0
2026-05-11 17:37:45 +00:00
dadaloop82 d056a6a116 fix: expired section hides items with quantity=0
Query was missing AND i.quantity > 0, so thrown-away items (qty=0)
with a past expiry_date kept appearing in the expired list.
Also cleaned up the orphan row for Aglio in the DB.
2026-05-11 17:35:53 +00:00
github-actions[bot] cdc776d7c7 chore: auto-merge develop → main
Triggered by: cb39b63 fix: drastically reduce false-positive consumption anomaly banners
2026-05-11 17:33:31 +00:00
dadaloop82 cb39b63997 fix: drastically reduce false-positive consumption anomaly banners
Two changes:
1. Skip prediction when expected_qty=0 — model says 'should be finished'
   but user simply restocked or consumed less. Not actionable.
2. Raise 'more than expected' threshold to 400% (was 30%).
   Having more than expected almost always means a restock the model
   doesn't know about yet — only truly extreme cases (>4x) are flagged.
   'Less than expected' stays at 30% (still actionable: unregistered use).
2026-05-11 17:31:41 +00:00
github-actions[bot] 1eb7a6733c chore: auto-merge develop → main
Triggered by: 5b401f8 fix: consumption predictions false positives after restocking
2026-05-11 17:26:31 +00:00
dadaloop82 5b401f8d5f fix: consumption predictions false positives after restocking
Root cause: baseline was 'restockQty' (only the new items added) but
actualQty = pre-existing stock + new items → always looked like 'more than expected'.

New approach: baseline = current_qty + consumed_since_restock.
This correctly reflects the true starting point regardless of pre-existing stock,
eliminating all false positives after shopping trips.
2026-05-11 17:24:44 +00:00
github-actions[bot] 448a8237f6 chore: auto-merge develop → main
Triggered by: 20c1640 fix: inventory_update now records compensating transactions
2026-05-11 17:23:38 +00:00
dadaloop82 20c16401d2 fix: inventory_update now records compensating transactions
When a user manually edits quantity (e.g. after restocking), the diff
is recorded as 'in' or 'out' transaction with note '[Correzione manuale]'.
This prevents the anomaly detector from flagging manual edits as phantom
or missing consumption.
2026-05-11 17:21:57 +00:00
github-actions[bot] 5ffc4581f4 chore: auto-merge develop → main
Triggered by: a9a512e fix: clear opened_at on sealed packages row during split
2026-05-11 17:20:37 +00:00
dadaloop82 a9a512e014 fix: clear opened_at on sealed packages row during split
When usage splits a row into 'whole sealed packages' + 'opened fraction',
the sealed row was updated without clearing opened_at — if it had been
opened previously, the stale flag would persist and wrongly show
'aperto da N giorni' on intact packages.

Now all 3 split paths (conf early-split, conf post-split, g/ml/l split)
explicitly set opened_at = NULL on the sealed row.
2026-05-11 17:19:00 +00:00
github-actions[bot] 7cc4ce91a9 chore: auto-merge develop → main
Triggered by: 3391106 feat: banner opened items show 'aperto da X giorni in frigo' instead of 'scaduto'
2026-05-11 17:12:53 +00:00
dadaloop82 3391106010 feat: banner opened items show 'aperto da X giorni in frigo' instead of 'scaduto'
When inventory item has opened_at set, the expired banner now shows:
- Title: '[Nome] — Aperto da troppo tempo!' (instead of '— Scaduto!')
- Detail: 'Aperto da N giorni in [icon] [location] · hai ancora X'
Also removed hardcoded Italian 'scade il' string from non-opened expired detail.
2026-05-11 17:11:07 +00:00
github-actions[bot] 184042a0ae chore: auto-merge develop → main
Triggered by: 85090ec fix: generic 'latte' opened shelf life 4→7 days (UHT default)
2026-05-11 17:09:26 +00:00
dadaloop82 85090ecc9f fix: generic 'latte' opened shelf life 4→7 days (UHT default)
Fresh milk is explicitly matched by 'latte fresco/intero/parzial/scremato' (3 days).
Generic 'Latte' without qualifier is almost always UHT in Italian households — 7 days.
2026-05-11 17:07:59 +00:00
github-actions[bot] 27238a39cb chore: auto-merge develop → main
Triggered by: 8407dea fix: editBannerNoExpiry load inventory before opening edit modal
2026-05-11 16:58:48 +00:00
github-actions[bot] 6cb7aeaf5b chore: auto-merge develop → main
Triggered by: e3975b7 fix: editBannerNoExpiry called undefined openEditInventoryModal
2026-05-11 16:57:11 +00:00
dadaloop82 8407dea781 fix: editBannerNoExpiry load inventory before opening edit modal
currentInventory is empty on dashboard. Fetch inventory_list first
(same pattern as editReviewItem and weighBannerItem).
2026-05-11 16:57:00 +00:00
dadaloop82 e3975b7d2e fix: editBannerNoExpiry called undefined openEditInventoryModal
Replace with correct editInventoryItem() call — same function used
by all other banner edit handlers.
2026-05-11 16:55:26 +00:00
github-actions[bot] b5789bbc8b chore: auto-merge develop → main
Triggered by: 38c6c5a fix: auto-create data dir on first Docker run (HY000[14])
2026-05-11 15:56:26 +00:00
dadaloop82 38c6c5aac3 fix: auto-create data dir on first Docker run (HY000[14])
When a Docker named volume is first mounted at /var/www/html/data,
the directory may be owned by root (the volume is created empty before
the image's chown step applies). This caused PDO::__construct to throw:
  SQLSTATE[HY000][14] unable to open database file

Fix: _ensureDataDir() checks/creates the directory and attempts chmod
before every getDB() call. On subsequent calls the is_dir+is_writable
checks are O(1) stat calls with no overhead.

Fixes #34 (also closes #28 #29 #30 #31 #32 #33)
2026-05-11 15:54:40 +00:00
github-actions[bot] fc2849be19 chore: auto-merge develop → main
Triggered by: a21b54d feat: i18n — translate all hardcoded Italian strings (nutrition, facts, kiosk, gemini, scanner, shopping)
2026-05-11 15:51:53 +00:00
dadaloop82 a21b54deaa feat: i18n — translate all hardcoded Italian strings (nutrition, facts, kiosk, gemini, scanner, shopping)
- Added 106 new translation keys across all 3 languages (it/en/de):
  - nutrition.* (11 keys): card title, score labels, health/variety/fresh bars, source
  - facts.* (70 keys): screensaver facts — greetings, expiry, shopping, categories, tips
  - kiosk.* (12 keys): update check, install flow, exit/refresh button titles
  - update.* (2 keys): badge label and button
  - gemini.* (2 keys): chat button title, not-configured tooltip
  - dashboard.banner_explain_title/btn/analyzing (3 keys): anomaly explain button
  - add.history_badge_tip (1 key): history badge tooltip
  - shopping.smart_last_update, names_already_updated (2 keys)
  - appliances.empty (1 key)
  - scanner.save_new_btn (1 key)
- app.js: replaced all remaining hardcoded Italian strings with t() calls
- api/index.php: fixed Frutta/Früchte Bring! loop (Pass 2 genericQualifiers)
- index.html: asset version bumped to v=20260511b
2026-05-11 15:49:55 +00:00
github-actions[bot] 0fd39db5d3 chore: auto-merge develop → main
Triggered by: da62647 feat: v1.7.9 — category badges, category search, AI guards
2026-05-11 05:55:10 +00:00
dadaloop82 da62647089 feat: v1.7.9 — category badges, category search, AI guards
- Category badge on every inventory item (icon + label); 'altro' items
  refined asynchronously via new guess_category Gemini endpoint
  (data/category_ai_cache.json) — no AI call when key not configured
- Category search: inventory search now matches by macro-category key
  and translated label (e.g. 'biscotti' finds all cookie items)
- Brand fast-path in guessCategoryFromName (Oreo, Barilla, Lavazza…)
- Fix: duplicate banner alerts — _bannerLoading guard + _queuedItemIds Set
- Fix: mapToLocalCategory with en:dairies (dairi stem added)
- Fix: mapToLocalCategory no longer blocks on 'altro' — falls back to
  guessCategoryFromName(productName) before returning 'altro'
- Fix: 'Tonno all'olio' was resolving to condimenti — moved tonno\b
  check before olio\b in conserve regex block
- AI guards: _refineCategoryBadgesAsync and fetchAllPrices now check
  _geminiAvailable (JS); getShoppingPrice returns no_api_key (PHP)
  when GEMINI_API_KEY is not set — all AI functions are now explicit
2026-05-11 05:53:15 +00:00
github-actions[bot] b65e381329 chore: auto-merge develop → main
Triggered by: 763b7fd fix: bilancia ricette attende ≥5g di variazione; sale spurio in Bring!
2026-05-10 15:47:36 +00:00
dadaloop82 763b7fd057 fix: bilancia ricette attende ≥5g di variazione; sale spurio in Bring!
- Recipe use modal: reset _scaleLastConfirmedGrams al peso attuale prima
  di aprire il modale, così la tara ha tempo; soglia ridotta 10→5g
- PHP useFromInventory: prima di auto-aggiungere a Bring! un prodotto esaurito,
  controlla se la famiglia shopping_name ha scorte da altri prodotti (es.
  'Sale marino iodato' esaurito ma 3kg di altri sali in dispensa → non aggiunge)
  JS, così il cron bringCleanupObsolete può auto-rimuovere
- Rimosso manualmente 'Sale' da Bring! (aggiunto senza marker dalla vecchia logica)
2026-05-10 15:45:56 +00:00
github-actions[bot] 8bff68dd33 chore: auto-merge develop → main
Triggered by: d1139a7 fix: falso alert burro; JSON traduzioni corrotte; allineamento inventario
2026-05-10 15:35:57 +00:00
dadaloop82 d1139a7e4b fix: falso alert burro; JSON traduzioni corrotte; allineamento inventario
- Smart shopping: aggiungi family-coverage check per prodotti 'quasi finiti'.
  Se il shopping_name family ha scorte da altri prodotti (es. Burro conf)
  con unità diff (g/ml vs conf), l'alert 'sta finendo' viene soppresso.
- Corretto bug traduzioni: sezione 'action' duplicata in de/en/it.json
  causava JSONDecodeError in CI/CD (line 944 column 2).
- DB: allineamento inventario burro — rimossi 30g residui (usati),
  pulito opened_at da pacco nuovo Burro conf (comprato 2026-05-08).
2026-05-10 15:34:29 +00:00
dadaloop82 5fccb5309c feat: Crea una ricetta per ingrediente + fix bottone Apri ricetta + meal non categorizzato
- Bottone 'Apri la ricetta': il transfer btn si trasforma direttamente in
  '📖 Apri la ricetta' dopo il successo (invece di aggiungere un elemento DOM separato)
- meal null: chatToRecipe e recipe_from_ingredient non auto-categorizzano il pasto;
  renderRecipe mostra il tag meal solo se presente
- Nuovo endpoint recipe_from_ingredient: genera una ricetta con l'ingrediente
  selezionato come protagonista, stessa pipeline di chatToRecipe (Gemini + fuzzy-match)
- Bottone '👨‍🍳 Crea una ricetta con questo' nel pannello azione degli alimenti
  (span-2 sotto la griglia 2x2), apre overlay Ricette in loading state
2026-05-10 15:21:21 +00:00
github-actions[bot] a99e2cb80b chore: auto-merge develop → main
Triggered by: 63ede4f fix: increase maxOutputTokens to 8192 in chatToRecipe; add 'Apri la ricetta' button after transfer
2026-05-10 15:10:08 +00:00
dadaloop82 63ede4fb53 fix: increase maxOutputTokens to 8192 in chatToRecipe; add 'Apri la ricetta' button after transfer
Fixes parse_error on complex recipes (JSON was truncated at 2048 tokens).
After successful transfer, shows 'Apri la ricetta' button inline in chat
alongside the ' Aggiunta alle Ricette!' button.
Closes #27
2026-05-10 15:08:24 +00:00
github-actions[bot] 5028fb0e72 chore: auto-merge develop → main
Triggered by: 370a5a6 fix: robust JSON extraction in chatToRecipe — handles Gemini preamble text + nested fences
2026-05-10 15:04:32 +00:00
dadaloop82 370a5a62b0 fix: robust JSON extraction in chatToRecipe — handles Gemini preamble text + nested fences 2026-05-10 15:02:58 +00:00
github-actions[bot] 75eb77c9b1 chore: auto-merge develop → main
Triggered by: ac7368e fix: button outside chat bubble + showToast on success/error in chatTransferToRecipes
2026-05-10 15:01:48 +00:00
dadaloop82 ac7368e49d fix: button outside chat bubble + showToast on success/error in chatTransferToRecipes 2026-05-10 15:00:19 +00:00
github-actions[bot] 2db01b8cd3 chore: auto-merge develop → main
Triggered by: 2f04543 fix: use 'persons' field (not 'servings') in chatToRecipe for renderRecipe compatibility
2026-05-10 14:56:07 +00:00
dadaloop82 2f04543de3 fix: use 'persons' field (not 'servings') in chatToRecipe for renderRecipe compatibility 2026-05-10 14:54:29 +00:00
github-actions[bot] 61725b17da chore: auto-merge develop → main
Triggered by: 06cba1e fix: add missing chatTransferToRecipes function to app.js
2026-05-10 14:52:08 +00:00
github-actions[bot] c831387f70 chore: auto-merge develop → main
Triggered by: 073b4b9 v1.7.8: Trasferisci a Ricette dalla chat (refactor)
2026-05-10 14:50:50 +00:00
dadaloop82 06cba1ea71 fix: add missing chatTransferToRecipes function to app.js 2026-05-10 14:50:32 +00:00
dadaloop82 073b4b9cfa v1.7.8: Trasferisci a Ricette dalla chat (refactor)
- Sostituisce 'Usa ingredienti' inline con 'Trasferisci a Ricette'
- Nuovo endpoint chat_to_recipe: Gemini restituisce JSON completo
  (title, meal, servings, ingredients, steps, nutrition_note),
  PHP arricchisce tutti gli ingredienti con product_id/location
  via fuzzy-match identico a generateRecipe
- La ricetta viene salvata in archivio e si apre nell'overlay Ricette
  con tutti i pulsanti Usa, modalità cottura, salvataggio intatto
- Rimossi: chatExtractIngredients, _buildChatIngredientPanelHTML,
  _chatRecipeTitle, chat_extract_recipe, chat-recipe-panel CSS
2026-05-10 14:49:08 +00:00
github-actions[bot] b1a3fbed5a chore: auto-merge develop → main
Triggered by: 9973edf v1.7.8: usa ingredienti dalla chat
2026-05-10 14:42:05 +00:00
dadaloop82 9973edf463 v1.7.8: usa ingredienti dalla chat
- Nuovo endpoint chat_extract_recipe: Gemini estrae solo nomi+quantità
  con prompt minimo (nessun inventario nel prompt → niente troncamento),
  poi PHP fuzzy-match contro l'inventario completo identico a generateRecipe
- Frontend: _looksLikeRecipe() rileva risposte chat con ricetta;
  bottone '🥄 Usa ingredienti' appare sotto la bubble, chiama chatExtractIngredients()
  che mostra pannello inline con pulsanti '📦 Usa' per ogni ingrediente in dispensa
- useRecipeIngredient() riusato 1:1 con fallback _chatRecipeTitle per le note
- Stili CSS: btn-chat-use-recipe, chat-recipe-panel, chat-recipe-panel-container
- Chiavi i18n: use_ingredients_btn, recipe_ingredients_from_pantry (it/en/de)
2026-05-10 14:40:25 +00:00
github-actions[bot] b23921fc83 chore: auto-merge develop → main
Triggered by: 5462879 fix: chat response truncated at 'Ingredienti:' (MAX_TOKENS)
2026-05-10 14:21:24 +00:00
dadaloop82 5462879783 fix: chat response truncated at 'Ingredienti:' (MAX_TOKENS)
- Move system prompt to systemInstruction API field instead of injecting
  it as a fake user/model turn, saving the full turn's token count from
  the context window used for generation
- Increase maxOutputTokens from 1500 to 4096 so full recipes (with
  ingredients + instructions) can complete without being cut off
- Increase API timeout from 60 to 90 seconds for longer responses
finish_reason changes from MAX_TOKENS → STOP, reply goes from 265 to 2108 chars
2026-05-10 14:19:41 +00:00
github-actions[bot] 97538822ef chore: auto-merge develop → main
Triggered by: 7de556e fix: bread machine support in chat + appliances prompt
2026-05-10 14:08:17 +00:00
dadaloop82 7de556e25c fix: bread machine support in chat + appliances prompt
- Add 'macchina del pane' to multiFunction list and capabilityMap with
  bread-specific instructions (ingredient order: liquids → flour → salt →
  sugar → yeast on top; programs: Base, Integrale, Francese, Rapido, Dolce)
- Fix compact appliances prompt: when multiple specialized appliances exist,
  list each with capabilities instead of forcing 'PREFERISCI Cookeo' (which
  caused Gemini to ignore the user's explicit bread machine request)
- Add chat rule #10: when user asks for a specific appliance recipe, always
  provide instructions tailored to that device only
2026-05-10 14:06:35 +00:00
github-actions[bot] 6929a226b9 chore: auto-merge develop → main
Triggered by: 93684c5 ux: merge vacuum sealed question into move-after-use modal
2026-05-10 13:25:21 +00:00
dadaloop82 93684c5842 ux: merge vacuum sealed question into move-after-use modal
Instead of a separate floating prompt after use, the vacuum sealed checkbox
is now shown directly inside the 'where to put the rest?' modal:
- Always shown for container-type units (conf/g/kg/ml/l) or if previously sealed
- Pre-checked when the item was already vacuum sealed (semi-automatic)
- Saving on 'rimani qui' button also persists the vacuum state
- Saves one step: user answers location + vacuum in a single interaction
2026-05-10 13:23:40 +00:00
github-actions[bot] 7913bf4de4 chore: auto-merge develop → main
Triggered by: 75ca49a fix: smart shopping family suppression, shelf life pre-warming (v1.7.7)
2026-05-10 13:20:28 +00:00
dadaloop82 75ca49ac4e fix: smart shopping family suppression, shelf life pre-warming (v1.7.7)
- Remove recentlyExhausted bypass from shopping_name family suppression:
  products recently exhausted (<14d) were incorrectly flagged as critical
  even when the same family had ample stock (Yogurt 2002g, Affettato 1022g,
  Pane 400g). recentlyExhausted now only bypasses loose token-based coverage.
- Add prewarmShelfLifeCache() in cron: pre-warms opened shelf life via
  Gemini AI (max 5 items/cycle) so the UI never blocks on first load.
2026-05-10 13:18:41 +00:00
github-actions[bot] 3a9051e8c8 chore: auto-merge develop → main
Triggered by: ed447d5 fix: codebase audit fixes — indexes, daily_rate, anomaly key, CSRF, chat pruning, shopping_name
2026-05-10 11:27:50 +00:00
dadaloop82 ed447d5811 fix: codebase audit fixes — indexes, daily_rate, anomaly key, CSRF, chat pruning, shopping_name
## v1.7.6

- DB: fix shopping_name Pi→Piadina, Grana→Formaggio, Prosciutto cotto→Affettato, Panna acida→Panna
- DB: composite indexes idx_transactions_type_date + idx_transactions_pid_type_undone (+ migration)
- PHP: daily_rate uses first_in→last_activity window (not first_in→now)
- PHP: anomaly dismiss key uses product_id+direction (stable, not product_id+round(expected))
- PHP: smart shopping — products exhausted within 14 days bypass token/family suppression
- PHP: chat pruning — DELETE messages beyond 200 after each chatSave()
- PHP: getStats() — 5 queries → 1 consolidated query with subselects
- PHP: bringCleanupObsolete — 300ms delay between bulk removals
- PHP: CSRF guard — POST write actions require X-EverShelf-Request:1 or Content-Type:application/json
- JS: api() — sends X-EverShelf-Request:1 on all POST requests
- JS: _opLog — prunes entries older than 30 days in addition to 200-entry cap
2026-05-10 11:26:10 +00:00
github-actions[bot] 6284116525 chore: auto-merge develop → main
Triggered by: f65fb43 fix: shopping list accuracy, Bring! cleanup server-side, vacuum prompt, recipe appliances
2026-05-10 10:56:16 +00:00
dadaloop82 f65fb4365c fix: shopping list accuracy, Bring! cleanup server-side, vacuum prompt, recipe appliances
## v1.7.5

### Added
- Vacuum sealed prompt after item use (conf/weighted units, auto-dismiss 8s)
- Multi-function appliance awareness in Gemini recipe prompts (Cookeo/Bimby/Thermomix)
- Server-side Bring! cleanup in cron (no client page load required)
- shopping_name field in inventory_list API response

### Fixed
- Bring! cleanup: false token match (Succo/Frutta from product name tokens)
- Bring! cleanup: expired item with fresh family stock no longer flagged critical (Verdure)
- Bring! remove: catalog items now removed via German key fallback (Formaggio→Käse)
- Shopping list: isExpiringSoon false positives (requires pctLeft < 50%)
- Shopping list: expired batch suppressed when fresh restock >= 50%
- Cross-device cleanup: detect app-added items via spec markers not localStorage
- API fetch: cache: 'no-store' on all api() calls
- Shopping page: 45s polling for multi-client sync
2026-05-10 10:54:35 +00:00
github-actions[bot] 1102579ea8 chore: auto-merge develop → main
Triggered by: 10114da errors: report EVERY server/gateway error to GitHub Issues
2026-05-08 11:35:47 +00:00
dadaloop82 10114dae50 errors: report EVERY server/gateway error to GitHub Issues
PHP api/index.php:
- DB connection failure (500) now calls _phpErrorReport()
- Main router catch-all (500) now calls _phpErrorReport()
- undoTransaction DB error (500) now calls _phpErrorReport()

PHP api/cron_smart_shopping.php:
- cron Throwable catch now calls _phpErrorReport() before exit(1)
  (fires even in CRON_MODE since _phpErrorReport() has its own guard)

Scale Gateway GatewayWebSocketServer.kt:
- onError() now calls ErrorReporter.report(ex, ...) in addition to Log.e

Combined with previous kiosk commit, every error path in the entire
EverShelf stack now sends an automatic GitHub Issue.
2026-05-08 11:34:06 +00:00
github-actions[bot] 9ab49c1d41 chore: auto-merge develop → main
Triggered by: 96d3514 kiosk: report ALL crashes via ErrorReporter
2026-05-08 11:29:01 +00:00
dadaloop82 96d3514c38 kiosk: report ALL crashes via ErrorReporter
- onRenderProcessGone: WebView renderer crash/OOM kill now reported
  and Activity is recreated automatically (no more silent crash)
- onReceivedHttpError: HTTP 5xx from server now reported
- onLowMemory: system low-memory event reported
- onTrimMemory: moderate+ memory trim events reported

Every error path in the kiosk now sends an auto-report issue.
2026-05-08 11:27:28 +00:00
dadaloop82 584fcf5fed Merge develop: fix price calc (pkgUnit fallback + fuzzy lookup) 2026-05-08 06:21:40 +00:00
dadaloop82 336d9091be fix: pkgUnit fallback for /kg+/L, fuzzy smart lookup by word-prefix 2026-05-08 06:21:35 +00:00
dadaloop82 fa9bafef8f Merge develop: fix shopping price calculations 2026-05-08 06:11:14 +00:00
dadaloop82 3e6e8dc0c7 fix: pz×container multiplication, approx badge for null-total items 2026-05-08 06:11:09 +00:00
dadaloop82 5201fb7c58 Merge develop: fix shopping price calc + use-all UX 2026-05-08 06:02:45 +00:00
dadaloop82 be8dfe9e1e fix: shopping price calc — null for unconvertible /kg items, resolved qty in response/badge 2026-05-08 06:02:40 +00:00
dadaloop82 07e9db3442 Merge develop: fix use-all confirm HTML + disambiguation UX 2026-05-08 05:48:04 +00:00
dadaloop82 7cb1dfe285 fix: no HTML in use-all confirm dialog; rework disambiguation UX flow 2026-05-08 05:48:00 +00:00
dadaloop82 ef9a8c5c2f fix: price crash with kg-priced items 2026-05-07 20:56:06 +00:00
dadaloop82 f4dbd151a8 fix: getAllShoppingPrices TypeError on null estimated_total for /kg items; fallback to 1-unit price 2026-05-07 20:56:03 +00:00
dadaloop82 705e1f2d95 fix: price refresh fast + button unlock 2026-05-07 20:43:28 +00:00
dadaloop82 4f98a63414 fix: refresh btn busts only total cache (fast); fix _pricesFetching button lock 2026-05-07 20:43:26 +00:00
dadaloop82 d766786e28 fix: batch price PHP timeout + refresh forces recompute 2026-05-07 20:39:46 +00:00
dadaloop82 d2e5eea05b fix: extend PHP timeout for batch price fetch; refresh btn forces recompute 2026-05-07 20:39:44 +00:00
dadaloop82 f1237ed271 fix: prices always fetched from server, no stale client cache 2026-05-07 20:35:40 +00:00
dadaloop82 b412cc0ebe fix: server is single source of truth for prices — no sessionStorage preload 2026-05-07 20:35:37 +00:00
dadaloop82 c28195b250 perf: batch price fetch 2026-05-07 20:29:00 +00:00
dadaloop82 7a51a44b86 perf: batch Gemini price fetch — 1 call for all missing items instead of N 2026-05-07 20:28:58 +00:00
dadaloop82 bc36c93c08 feat: screensaver shows shopping total 2026-05-07 19:29:55 +00:00
dadaloop82 c02a2fc632 feat: screensaver shopping panel with item count and estimated total 2026-05-07 19:29:53 +00:00
dadaloop82 7fd2da1d67 fix: price cache timestamp prevents stale totals across clients 2026-05-07 18:58:13 +00:00
dadaloop82 dfbdbc6efb fix: use timestamp-based price cache validity; invalidate stale per-item cache 2026-05-07 18:58:11 +00:00
dadaloop82 12560dd4c1 release: v1.7.4 — server-side centralized price totals 2026-05-07 18:55:40 +00:00
dadaloop82 6c342a412b fix: centralize price totals server-side; batch API call; 5-min total cache 2026-05-07 18:55:37 +00:00
dadaloop82 12934a045c release: v1.7.4 patch — skip price reload on tab open 2026-05-07 18:50:07 +00:00
dadaloop82 a01ca583ea fix: skip price fetch on first render when smart data empty; bump header to v1.7.4 2026-05-07 18:50:04 +00:00
dadaloop82 47fe51148f Merge branch 'main' of github-evershelf:dadaloop82/EverShelf 2026-05-07 17:55:33 +00:00
dadaloop82 acd6ddb140 release: v1.7.4 — AI price estimation for shopping list 2026-05-07 17:55:18 +00:00
dadaloop82 13b55104a1 chore: release v1.7.4 — AI price estimation, dashboard badge, real-time total 2026-05-07 17:55:05 +00:00
github-actions[bot] 5d9eadf691 chore: auto-merge develop → main
Triggered by: bcd7580 fix: show price total on dashboard via sessionStorage fallback
2026-05-07 17:50:15 +00:00
github-actions[bot] ffa3daba0e chore: auto-merge develop → main
Triggered by: 1584d40 fix: replace stat-urgent with green price total badge top-right
2026-05-07 17:48:23 +00:00
dadaloop82 bcd7580729 fix: show price total on dashboard via sessionStorage fallback 2026-05-07 17:48:18 +00:00
github-actions[bot] 6c11c0105a chore: auto-merge develop → main
Triggered by: 20192f9 fix: prices cached on tab switch; background price fetch every 2min; stat-price-total bigger
2026-05-07 17:47:11 +00:00
dadaloop82 1584d402e4 fix: replace stat-urgent with green price total badge top-right 2026-05-07 17:46:20 +00:00
dadaloop82 20192f902d fix: prices cached on tab switch; background price fetch every 2min; stat-price-total bigger 2026-05-07 17:44:56 +00:00
github-actions[bot] e06d2d506a chore: auto-merge develop → main
Triggered by: b9082ea fix: log.title emoji; add price estimate total to shopping stat card
2026-05-07 17:43:27 +00:00
dadaloop82 b9082eae52 fix: log.title emoji; add price estimate total to shopping stat card 2026-05-07 17:41:41 +00:00
github-actions[bot] 60da4e6989 chore: auto-merge develop → main
Triggered by: 0de9a62 fix: price estimate for all items, including manually-added ones
2026-05-07 17:39:56 +00:00
dadaloop82 0de9a62058 fix: price estimate for all items, including manually-added ones
- AI prompt: always return a best-guess price (never null/price_not_found)
  for unrecognised items returns generic package estimate with '~' prefix
- Cache key bumped to v3 to invalidate old null-returning cache entries
- JS: manually-added items (no smart match, no spec) default to qty=1/conf
  instead of qty=1/pz so _calcEstimatedTotal treats them as a single pack
- Price badge: shows '~€X.XX' prefix when source_note starts with '~'
  so user knows the price is a rough estimate
2026-05-07 17:38:05 +00:00
github-actions[bot] 3aabaeccb7 chore: auto-merge develop → main
Triggered by: 3a9f0cc fix: bump asset versions to force cache bust; price rate limit own bucket
2026-05-07 17:35:50 +00:00
dadaloop82 3a9f0ccf79 fix: bump asset versions to force cache bust; price rate limit own bucket
- app.js and style.css versioned to 20260507a so browsers load new code
- get_shopping_price / get_all_shopping_prices moved to dedicated 'price'
  rate-limit bucket (60 req/min) separate from general (120 req/min)
  to avoid false 429s during sequential per-item price loading
2026-05-07 17:34:04 +00:00
github-actions[bot] dfe14c1f34 chore: auto-merge develop → main
Triggered by: 5f510c0 feat: AI price estimation for shopping list with per-item real-time display
2026-05-07 17:33:14 +00:00
dadaloop82 5f510c0451 feat: AI price estimation for shopping list with per-item real-time display
- Add get_shopping_price / get_all_shopping_prices API endpoints
- AI (Gemini) estimates retail price per natural unit (pack, piece, bunch)
  instead of always per-kg — avoids absurd totals like €1609
- _calcEstimatedTotal: proper g/ml→package conversion using defQty + regex
  on unit_label; only 'kg'/'l' labels trigger weight/volume math
- Cache key bumped to v2 to invalidate old per-kg cached entries
- Suggested quantity cap lowered from 20 to 10 conf/pz
- Unit mismatch guard: if totalUsed >> buyCount*5 for unit=conf, use
  purchase frequency instead of raw consumption rate
- JS _buildPricePayload: use smartShoppingItems for qty/unit (not Bring! spec)
- JS _cachedPrices: persist in sessionStorage (survives navigation);
  validated by _qty/_unit metadata so stale totals auto-invalidate
- Price display redesigned: right-side column per row (price-col-main +
  price-col-unit) instead of small inline badge
- fetchAllPrices: buttons disabled immediately before guard check;
  running total uses only current shoppingItems (not Object.values cache)
- Background refresh: always silent (removed 90s interaction condition)
- visibilitychange: sets _bgCall=true for shopping before refreshCurrentPage
- .gitignore: add runtime data files (bring_migrate_ts, shopping_price_cache,
  anomaly_dismissed, opened_shelf_cache, shopping_name_cache)
- Remove bring_catalog.json and bring_migrate_ts.json from tracking
2026-05-07 17:31:23 +00:00
github-actions[bot] 7005708e95 chore: auto-merge develop → main
Triggered by: 4196130 feat: AI suggestions, smart shopping qty, shelf life fixes, UX polish
2026-05-07 06:20:38 +00:00
dadaloop82 4196130835 feat: AI suggestions, smart shopping qty, shelf life fixes, UX polish
- bringSuggestItems(): Gemini AI for seasonal/complementary suggestions (6h cache)
- renderSuggestions(): AI badge (🤖 AI) for AI-sourced items + CSS .priority-ai
- smartShopping(): suggested_qty/unit/approx with package-aware tiers
- autoSyncUrgencySpecs(): sync suggested quantities to Bring! spec field
- estimateOpenedExpiryDays(): dairy-outside-fridge rules (panna 3d, yogurt 2d, latte 1d)
- AI shelf-life upper bound tightened to max(rule×4, 30) days
- Opened section: fix 0g display (remainderAmt >= 0.5 threshold, pkgSize guard)
- guessCategoryFromName(): expanded with 50+ new patterns (uova, herbs, vegetables...)
- Suggestions panel: excludes already-added Bring! items
- Shopping list: no re-render while suggestions panel is open
- Translations: remove duplicate 🍳 from dashboard.quick_recipe (all 3 langs)
- Scale icon: always white via filter:brightness(0)invert(1)
- opened_shelf_cache.json: remove 3 bad dairy entries (60d outside fridge)
2026-05-07 06:19:07 +00:00
dadaloop82 eeb2b512ad merge: screensaver timeout setting + gitignore kiosk build artifacts 2026-05-06 15:00:17 +00:00
dadaloop82 ffb0341eb6 feat: screensaver configurable timeout + fix gitignore (exclude kiosk build artifacts) 2026-05-06 15:00:04 +00:00
dadaloop82 459766fa80 merge develop → main: kiosk v1.7.0 OTA + signing + CI + nutrition dashboard + error reporting 2026-05-06 14:53:17 +00:00
dadaloop82 725e2ee5ee docs: update README with kiosk v1.7.0 features, OTA signing fix, Actions CI, nutrition dashboard 2026-05-06 14:53:07 +00:00
dadaloop82 709a8c769b merge: fix false update warning + Actions auto-publish kiosk APK with semver tag 2026-05-06 14:41:41 +00:00
dadaloop82 8535f4d4b9 fix: update check ignores non-semver tags + GitHub Actions builds versioned releases 2026-05-06 14:41:36 +00:00
dadaloop82 6c9478f09d merge: fix APK signing with project keystore 2026-05-06 14:26:04 +00:00
dadaloop82 cbb3f6b288 fix: use project keystore for consistent APK signing (fixes OTA update signature conflict) 2026-05-06 14:25:59 +00:00
dadaloop82 d793682362 merge develop: kiosk update old APK fallback fix 2026-05-06 14:22:05 +00:00
dadaloop82 484b378be9 fix: kiosk update button works in old APK, shows manual steps if installUpdate missing 2026-05-06 14:22:01 +00:00
dadaloop82 f93c5bf111 merge develop: kiosk update fallback message fix 2026-05-06 14:18:33 +00:00
dadaloop82 8d0ffef600 fix: kiosk update panel shows manual download link when APK too old 2026-05-06 14:18:29 +00:00
dadaloop82 3ad1cd6a52 merge develop: kiosk manual update check v1.7.0 2026-05-06 14:17:35 +00:00
dadaloop82 1c890c66ea feat: kiosk manual update check + install from settings
KioskActivity.kt:
- checkForUpdates(forceCheck, jsCallback): accepts force flag + optional
  JS callback to deliver result as JSON to the WebView
- New @JavascriptInterface checkForUpdates(): bypasses 6h throttle,
  calls back window._kioskUpdateResult({has_update, current, latest, apk_url})
- New @JavascriptInterface installUpdate(apkUrl): triggers APK download+install
- Improved error handling: HTTP status check + network error JSON response

app.js:
- window._kioskUpdateResult(): callback receives update check JSON,
  shows green/amber status box with version info, shows install button
- _kioskCheckForUpdates(): triggers native check, shows spinner while waiting
- _kioskInstallUpdate(): passes apk_url to native installUpdate()
- loadSettings(): shows #kiosk-update-panel when in kiosk WebView

index.html:
- #kiosk-update-panel: version label, 'Cerca aggiornamenti' button,
  status box, 'Installa aggiornamento' button (hidden until update found)
- CSS cache bump ?v=20260506e

build.gradle.kts: version 1.6.0 → 1.7.0 (versionCode 10 → 11)
2026-05-06 14:17:31 +00:00
dadaloop82 ed2b792722 merge develop: scale dot fix, kiosk update notice, live weight diag 2026-05-06 14:07:33 +00:00
dadaloop82 891733aa8c fix: scale dot white+glow, kiosk reconfigure fallback, live weight in settings
style.css:
- Connected dot: white fill + green border/glow (was green-on-green, invisible)

app.js:
- _kioskReconfigureScale(): show #kiosk-needs-update-notice + toast when
  kiosk APK is too old and reconfigureScale() method is missing
- _scaleUpdateStatus(): show/hide #scale-live-diag panel, update device + battery
- _scaleOnMessage weight: update #scale-diag-weight in real time
- _scaleOnMessage status: update #scale-diag-proto with BLE protocol

index.html:
- #kiosk-needs-update-notice: amber warning + download link inside kiosk panel
- #scale-live-diag: device name, battery, live weight readout, reconnect info
- CSS cache bump ?v=20260506d
2026-05-06 14:07:29 +00:00
dadaloop82 f3d9bc903c merge develop: nutrition analysis + screensaver pie charts 2026-05-06 14:00:22 +00:00
dadaloop82 e002cc4483 feat: nutrition analysis section + screensaver animated pie charts
app.js:
- _buildNutritionData(): category distribution, health/variety/freshness scores
- _renderNutritionSection(): animated 3D conic-gradient pie + legend + score bars
- _startInsightAlternation(): waste <-> nutrition fade-swap every hour
- _startScreensaverRotation(): facts and nutrition panel alternate every 5 min
- _renderScreensaverNutrition(): 3D animated pie + donut ring scores on screensaver
- _ssDonut(): CSS-only ring donut helper
- Removed two generic filler screensaver facts
- Cleaned up time-of-day screensaver facts (content-aware, no empty greetings)

index.html:
- Wrap waste/nutrition sections in #dashboard-insight-wrap
- Add #screensaver-nutrition slot in screensaver overlay
- Bump CSS cache ?v=20260506c

style.css:
- .ss-pie3d: 3D perspective + cubic-bezier spring + continuous slow spin
- .ss-donut-ring: CSS conic-gradient donut with bobbing animation
- .nutr-card, .nutr-pie-3d: dashboard nutrition card with 3D pie spin
- Score bars with fill transition
2026-05-06 14:00:13 +00:00
dadaloop82 2662e10b29 merge develop into main 2026-05-06 13:44:54 +00:00
dadaloop82 115c966322 fix: scale dot contrast + kiosk scale config panel + download banner in kiosk
style.css:
- Scale connected dot: bright #4ade80 fill + white border + double shadow
  so it pops on the dark green header (was white on green = invisible)

index.html:
- Scale settings tab: add kiosk panel with 'Riconfigura bilancia BLE'
  button (hidden in browser, shown in kiosk mode)
- Wrap gateway download section and WebSocket URL section with IDs
  so JS can hide them in kiosk mode
- CSS cache bust ?v=20260506b

app.js:
- syncSettingsFromDB: in kiosk mode hide scale gateway download section,
  WebSocket URL section and test button; show kiosk BLE panel instead;
  auto-set URL to ws://localhost:8765
- Add _kioskReconfigureScale() helper that calls _kioskBridge.reconfigureScale()

KioskActivity.kt:
- Add reconfigureScale() @JavascriptInterface: stops GatewayService,
  clears saved scale device prefs, launches SetupActivity at step 4
- Import GatewayService
2026-05-06 13:44:50 +00:00
dadaloop82 22bbf46d95 merge develop into main 2026-05-06 11:24:33 +00:00
dadaloop82 f04e227cc0 fix: kiosk title center + location pref 2 uses + update confirm before download
app.js:
- _injectKioskOverlay: move kiosk-mode class assignment BEFORE the
  _kiosk_overlay existence guard — fixes race where Kotlin onPageFinished
  injects buttons during the await api() pause, then JS skips the class
- _PREF_LOC_NEEDED: 3 → 2 (remember use-location after 2 picks, not 3)

KioskActivity.kt:
- showNativeUpdateBanner: remove auto-start of triggerApkDownload;
  banner now shows with 'Scarica' button enabled, download only starts
  when user taps it (confirms before install)
2026-05-06 11:24:29 +00:00
dadaloop82 1013118db3 merge develop into main 2026-05-06 11:18:42 +00:00
dadaloop82 6d098a80a6 fix: scale auto-reconnect + reconfigure button in kiosk settings
GatewayService:
- onScanStopped() now calls scheduleReconnect() when not connected, so
  if the scale is off at startup the service keeps retrying every 8s
  instead of giving up after the first 20s scan window

SettingsActivity:
- Add 'Riconfigura bilancia' button (yellow outline) when a scale is
  already configured — clears saved device, stops the gateway service,
  and opens SetupActivity directly at step 4 (scale scan step)

SetupActivity:
- Accept 'start_step' intent extra to jump directly to a specific step
  (used by SettingsActivity reconfigure flow)
2026-05-06 11:18:38 +00:00
dadaloop82 8322e535b0 merge develop into main 2026-05-06 11:12:24 +00:00
dadaloop82 8eba5c8573 fix: header title left-aligned by default, centered only in kiosk mode 2026-05-06 11:12:20 +00:00
dadaloop82 2a43b9ccfb merge develop into main 2026-05-06 05:35:41 +00:00
dadaloop82 6f19d1bcd5 fix: screensaver watcher not activated when enabled from settings UI
initInactivityWatcher() was called once at startup and returned early if
screensaver was disabled at that moment. Enabling it later from the
settings panel had no effect until page reload.

- Make initInactivityWatcher() idempotent: attach DOM listeners only once
  (flag _inactivityListenersAttached), check screensaver_enabled dynamically
  inside each handler so disabling/enabling is reflected immediately
- Call initInactivityWatcher() at end of saveSettings() so the inactivity
  timer starts immediately when user enables screensaver
2026-05-06 05:35:37 +00:00
dadaloop82 3c0f02b1c0 merge develop into main 2026-05-06 05:22:53 +00:00
dadaloop82 ed89e74b94 fix: split Bimby/Cookeo appliances, add missing i18n keys, improve use_all dialog
- .env: split 'Bimby/Moulinex Cookeo' into 'Bimby' and 'Moulinex Cookeo'
- translations (it/en/de): add use_all_confirm_*, throw_all_confirm_*,
  confirm.cancel, confirm.proceed, location.dispensa/frigo/freezer
- app.js: when ANY opened package exists, always show disambiguation
  dialog ('solo questa confezione?' vs 'tutto?') instead of silently
  finishing one — was previously showing dialog only for 2+ opened packages
2026-05-06 05:22:49 +00:00
dadaloop82 9ea85b238c merge develop into main 2026-05-06 05:19:41 +00:00
dadaloop82 f48fb02589 fix: center header title + bump to v1.7.3 for update detection
- CSS: header-title-wrap uses position:absolute with left:0/right:0
  + justify-content:center so title is truly centered regardless of
  button count on each side (kiosk has 2 left / 3 right)
- header-actions gets margin-left:auto to push right; both sides z-index:1
- Bump version 1.7.2 → 1.7.3 so already-open kiosk tabs will see
  the update badge once the new version is deployed to the server
- CSS cache bust: ?v=20260506a
2026-05-06 05:19:37 +00:00
dadaloop82 865de419e0 merge develop into main 2026-05-06 05:15:48 +00:00
dadaloop82 af9bae3093 fix: remove skeleton shimmer from dashboard stat cards 2026-05-06 05:15:44 +00:00
dadaloop82 c4498fa65d merge develop into main 2026-05-06 05:14:18 +00:00
dadaloop82 521d8f8e47 fix: screensaver init timing + gemini key not wiped on settings save
- app.js: move initInactivityWatcher() inside syncSettingsFromDB().then()
  so it reads screensaver_enabled after server sync, not stale localStorage
- app.js: skip gemini_key/bring_password in save_settings POST when empty
  to avoid overwriting server .env with blank values
- api/index.php: add screensaver_enabled to getServerSettings() + saveSettings()
2026-05-06 05:14:10 +00:00
github-actions[bot] edd1b2db42 chore: auto-merge develop → main
Triggered by: 14a7bbc fix: bust CSS cache (v20260505b) + bump webapp to v1.7.2
2026-05-05 18:41:24 +00:00
dadaloop82 14a7bbccbe fix: bust CSS cache (v20260505b) + bump webapp to v1.7.2
style.css was still served with ?v=20260421a so all CSS changes since
April 21 (scale indicator redesign, banner close button fix, etc.) were
invisible in the kiosk WebView — it used the cached old file.

Bumping manifest.json version 1.7.1→1.7.2 causes the auto-update
detector (_checkWebappUpdate) to fire on the kiosk: it compares the
loaded page version (1.7.1) with the server version (1.7.2), detects
a change, and shows the '⬆️ Aggiorna' badge so the user can reload.
2026-05-05 18:39:35 +00:00
github-actions[bot] cdfffc3b22 chore: auto-merge develop → main
Triggered by: c7b04f4 fix: alert banner — 'Spiega' button and title layout
2026-05-05 18:27:15 +00:00
dadaloop82 c7b04f410b fix: alert banner — 'Spiega' button and title layout
- explainBannerAnomaly: querySelector('#alert-banner .banner-detail')
  was wrong (class is 'alert-banner-detail'); function returned null → no-op.
  Fixed to getElementById('alert-banner-detail').
- alert-banner-inner: position close button absolutely (top:10px right:10px)
  so it is removed from the flex row and can never push the title-body to
  collapse or wrap under it. Inner gets padding-right:44px to reserve space.
- alert-banner-body: flex:1 1 0 + overflow:hidden for robust sizing
- alert-banner-title: word-break/overflow-wrap so very long product names
  wrap cleanly inside the body instead of overflowing
2026-05-05 18:25:16 +00:00
github-actions[bot] 1b6c641629 chore: auto-merge develop → main
Triggered by: 754f131 feat: kiosk setup improvements + webapp scale indicator fixes
2026-05-05 18:13:45 +00:00
dadaloop82 754f13111f feat: kiosk setup improvements + webapp scale indicator fixes
kiosk setup wizard:
- Scale step: ask user to power on the scale before scanning (new
  'Accendi la bilancia' card with 'Bilancia accesa → Cerca' button)
- BLE scan: filter results to only show compatible scales (scaleScore>0)
  hiding clearly non-scale BLE devices from the list
- After device selection: run a live connection test — connect to the
  scale, display the live weight, ask 'Corrisponde al peso sulla bilancia?'
  with  Sì /  Riprova / Skip buttons before confirming the device

webapp:
- Scale indicator not live on first load: scaleInit() was firing before
  syncSettingsFromDB() resolved; fixed by chaining .then(scaleInit)
- Scale icon green-on-green: connected state dot changed from #22c55e
  (green, invisible on dark-green header) to white with green border+glow,
  visible on any background color
2026-05-05 18:11:51 +00:00
github-actions[bot] e5e010b5a4 chore: auto-merge develop → main
Triggered by: 7ea5505 fix: scale indicator, logo crop, gateway LAN IP, setup spacing
2026-05-05 17:49:59 +00:00
dadaloop82 7ea5505a0d fix: scale indicator, logo crop, gateway LAN IP, setup spacing
webapp:
- Scale indicator: replace plain green dot with ⚖️ emoji + colored
  status badge (green/amber/grey/red); icon fades out when disconnected;
  tap shows a toast with device name + battery level
- Logo images: crop excess transparent padding from logo.png and
  logo_icon.png so content fills the frame at small display sizes
- style.css: reworked .scale-status-indicator CSS for emoji+badge

kiosk:
- SetupActivity: use device's real LAN IP for scale_gateway_url
  (was hardcoded 127.0.0.1 — only worked if server and kiosk run on
  the same machine); added getDeviceLanIp() helper (prefers wlan/eth)
- activity_setup.xml: reduce welcome step padding/margins so step 1
  fits on screen without scrolling; text sizes slightly reduced
- activity_setup.xml: fix feature bullet 'Bilancia Bluetooth via
  Gateway app' → 'Bilancia BLE integrata (nessuna app esterna)'
- strings.xml (en + it): rewrite all wizard_gateway_* strings to
  reflect integrated BLE service instead of external gateway APK
- ic_logo.png: regenerated at all densities from cropped source
2026-05-05 17:47:54 +00:00
github-actions[bot] 0a3ea44b82 chore: auto-merge develop → main
Triggered by: a509492 kiosk: remove all external gateway app references + update docs
2026-05-05 17:33:26 +00:00
dadaloop82 a5094920bf kiosk: remove all external gateway app references + update docs
- SettingsActivity: replace GATEWAY_PACKAGE / PackageManager check with
  GatewayService status; show BLE device name + live :8765 probe;
  buttons now restart GatewayService or redirect to setup wizard
- activity_settings.xml: rename section label to 'BILANCIA SMART',
  update button text to '⚙️ Configura bilancia'
- evershelf-scale-gateway/README.md: add DEPRECATED notice (gateway is
  now integrated into kiosk v1.6.0+, this app is no longer maintained)
- evershelf-kiosk/README.md: full rewrite — reflects v1.6.0, integrated
  BLE gateway, 6-step wizard, permissions table, protocol reference
- README.md: update kiosk features (remove gateway APK install/launch,
  add integrated BLE service), update scale section, update architecture,
  add kiosk v1.6.0 entry in Recent Updates
2026-05-05 17:31:14 +00:00
github-actions[bot] 2c56fb85c1 chore: auto-merge develop → main
Triggered by: 9cb29de kiosk: integrate BLE scale gateway + fix logo/branding
2026-05-05 17:26:52 +00:00
dadaloop82 9cb29de1f0 kiosk: integrate BLE scale gateway + fix logo/branding
- Kiosk v1.6.0 (versionCode 10)
  - Integrate BLE scale gateway directly into kiosk app (no external app needed)
    - New scale/ package: BleScaleManager, GatewayWebSocketServer, ScaleProtocol, GatewayService
    - GatewayService: foreground service, runs BLE scan + WebSocket :8765 server
    - Auto-reconnect on BLE disconnect; protocol compatible with old gateway app
  - Setup step 4: replace gateway install flow with BLE device scan + selection (mandatory)
  - Permissions: added BLUETOOTH_SCAN, BLUETOOTH_CONNECT, ACCESS_FINE_LOCATION (pre-S),
    FOREGROUND_SERVICE, FOREGROUND_SERVICE_CONNECTED_DEVICE
  - KioskActivity: replace launchGatewayInBackground() with startGatewayService()
  - checkForUpdates: remove gateway APK check (gateway is now internal)
  - Remove GATEWAY_PACKAGE / GATEWAY_DOWNLOAD_URL constants

- Logo / branding
  - logo.png + logo_icon.png: transparent background (no more black)
  - ic_logo.png regenerated in all densities
  - Removed house emoji (🏠) from web UI: favicon, bottom nav, setup wizard header
  - Removed 🏠 prefix from all translations (it/en/de) and manifest
  - Setup wizard: logo shown in language + welcome steps
  - Setup wizard: footer with credits ('Creato da Stimpfl Daniel • Open Source')
  - CSS: .nav-logo-icon for bottom nav logo sizing

- Scale Gateway v2.1.1 (versionCode 8)
  - Fix false update notification: replace == comparison with proper semverNewer()
    (was reporting 'update available' whenever tag != current, e.g. v2.1.0 != 2.1.0)
2026-05-05 17:24:24 +00:00
github-actions[bot] afc850557a chore: auto-merge develop → main
Triggered by: 8ee6fe8 kiosk: gateway auto-launch, update auto-download, gateway setup UX
2026-05-05 16:50:23 +00:00
dadaloop82 8ee6fe8770 kiosk: gateway auto-launch, update auto-download, gateway setup UX
- KioskActivity: move launchGatewayInBackground() BEFORE enableKioskLock() so
  Android's lock-task restriction does not block starting the gateway Activity
- KioskActivity: webView.clearCache(true) before loadUrl — no caching
- KioskActivity: checkForUpdates() uses proper semver > comparison (not !=)
  to avoid false-positive 'update available' when already up-to-date
- KioskActivity: showNativeUpdateBanner() removed 30s auto-hide, now auto-
  triggers download immediately when update detected
- SetupActivity: onResume() re-checks gateway status when returning from gateway
  config (user opens gateway, configures it, presses back → wizard refreshes)
- SetupActivity: checkGatewayStatus() probes TCP 127.0.0.1:8765 to show whether
  gateway is actually running, with clear 'not running' warning to configure first
- SettingsActivity: same TCP probe for live gateway status in settings screen
- build.gradle.kts: versionCode 9, versionName 1.5.3
2026-05-05 16:48:32 +00:00
github-actions[bot] bc9484d69e chore: auto-merge develop → main
Triggered by: 30aa2db branding: add EverShelf logo to kiosk splash + web header/preloader
2026-05-05 16:40:03 +00:00
dadaloop82 30aa2db0b9 branding: add EverShelf logo to kiosk splash + web header/preloader
- assets/img/logo/logo.png: trimmed full logo (icon + text, transparent bg)
- assets/img/logo/logo_icon.png: icon-only crop (no text, for header)
- drawable-*/ic_logo.png: multi-density PNGs for Android splash (mdpi→xxxhdpi)
- activity_kiosk.xml: replace ic_launcher_foreground with ic_logo at 260dp,
  remove redundant 'EverShelf' text row (already in logo image)
- index.html: add logo_icon.png in header title, logo.png in preloader
- style.css: add .app-preloader-logo and .header-logo-icon rules
2026-05-05 16:37:57 +00:00
github-actions[bot] 23c26fc9f4 chore: auto-merge develop → main
Triggered by: b8d91c0 kiosk: add missing Toast import in SetupActivity
2026-05-05 16:11:06 +00:00
dadaloop82 b8d91c0089 kiosk: add missing Toast import in SetupActivity 2026-05-05 16:09:09 +00:00
github-actions[bot] 642a9ae973 chore: auto-merge develop → main
Triggered by: 2ebf79d kiosk: fix strings.xml apostrophe syntax (&#39; -> \')
2026-05-05 16:07:50 +00:00
dadaloop82 2ebf79d394 kiosk: fix strings.xml apostrophe syntax (&#39; -> \') 2026-05-05 16:05:54 +00:00
github-actions[bot] 25d2a60b3a chore: auto-merge develop → main
Triggered by: 6500d22 kiosk: fix install dialog + screensaver always-on
2026-05-05 16:03:38 +00:00
dadaloop82 6500d22242 kiosk: fix install dialog + screensaver always-on
Fallback install (Intent.ACTION_VIEW):
- Remove FLAG_ACTIVITY_NEW_TASK: it caused startActivityForResult to return
  RESULT_CANCELED immediately, making the system installer dialog disappear in ~1s
- After fallback returns with app not installed: show '🔄 Riprova installazione'
  button that calls tryFallbackInstall() directly (skips PackageInstaller which
  is known to give STATUS=1 on this device)

Screensaver:
- KioskActivity.applyScreensaverFlag(): always add FLAG_KEEP_SCREEN_ON, never
  clear it — screen must ALWAYS stay on in kiosk mode
- The 'salvaschermo' toggle controls the in-app JS clock overlay (webapp setting),
  NOT the Android screen timeout
- finishSetup(): always push screensaver_enabled to webapp API (not just when scale
  is configured)
- SettingsActivity save: remove FLAG_KEEP_SCREEN_ON conditional; push
  screensaver_enabled to server API on save
- Update setup wizard description + strings to clarify in-app overlay vs screen off

Bump version to 1.5.2 (versionCode 8)
2026-05-05 16:01:47 +00:00
github-actions[bot] 8932f270b0 chore: auto-merge develop → main
Triggered by: 84d2ff0 kiosk: improve wizard UX + gateway configurability
2026-05-05 06:11:42 +00:00
dadaloop82 84d2ff0264 kiosk: improve wizard UX + gateway configurability
- Wizard step 4 (scale): skip question if scale already configured (KEY_HAS_SCALE=true), show gateway status directly
- Add '⚙️ Apri Gateway per configurarlo' button in setup wizard after gateway is installed
- Add same button in SettingsActivity SMART SCALE section (visible only when installed)
- Fix btnRunWizard in SettingsActivity: immediately launches SetupActivity instead of just showing a toast
- Bump version to 1.5.1 (versionCode 7)
2026-05-05 06:09:48 +00:00
github-actions[bot] 9edd3eae2a chore: auto-merge develop → main
Triggered by: b2a3343 fix(kiosk): warn user to press 'Done' not 'Open' after gateway install
2026-05-05 06:03:38 +00:00
dadaloop82 b2a334340b fix(kiosk): warn user to press 'Done' not 'Open' after gateway install
- Before launching system installer (ACTION_VIEW fallback), show a
  non-cancellable dialog warning: press Fine, NOT Apri
- After installer returns, force SetupActivity back to foreground in case
  user pressed Open anyway (FLAG_ACTIVITY_REORDER_TO_FRONT)
2026-05-05 06:01:53 +00:00
github-actions[bot] 2c786c6794 chore: auto-merge develop → main
Triggered by: 0f0ce68 fix(kiosk): fallback Intent.ACTION_VIEW when PackageInstaller STATUS=1 + fix self-update URL
2026-05-05 05:52:05 +00:00
dadaloop82 0f0ce684f1 fix(kiosk): fallback Intent.ACTION_VIEW when PackageInstaller STATUS=1 + fix self-update URL
- SetupActivity: catch STATUS_FAILURE=1 separately and immediately retry with
  Intent.ACTION_VIEW (system installer dialog) instead of showing a dead error.
  STATUS=1 is a generic PackageInstaller failure that can happen on many
  Android 14 devices even with a valid APK, but the system installer handles it.
- SetupActivity: remove misleading 'incompatibile' hint for status=1 (was wrong;
  STATUS_FAILURE_INCOMPATIBLE = 7, not 1).
- SetupActivity: deduplicate buildDeviceLabel() to shared private method
- KioskActivity: fix KIOSK_DOWNLOAD_URL to point to kiosk-latest release
  (was pointing to 'latest' which only has the gateway APK, so self-update
  was silently broken).
- Bump version 1.4.0 -> 1.5.0 (versionCode 5 -> 6)
2026-05-05 05:50:22 +00:00
github-actions[bot] 7ffd7d73e8 chore: auto-merge develop → main
Triggered by: abeb87c fix(kiosk): fix APK install failure — session lifecycle, error details, issue reporting
2026-05-05 05:34:22 +00:00
dadaloop82 abeb87c536 fix(kiosk): fix APK install failure — session lifecycle, error details, issue reporting
- KioskActivity: remove .use{} on PackageInstaller session to prevent premature
  session close causing STATUS_FAILURE=1; align with SetupActivity pattern
- SetupActivity: show full diagnostic info (status code + human-readable hint,
  device, Android version) in the UI card instead of just 'status=1'
- SetupActivity: use Build.PRODUCT/BOARD fallback when MANUFACTURER='unknown'
- ErrorReporter: add forceReport param to bypass in-session dedup for retries
- ErrorReporter: include Android SDK version in deviceInfo; fallback for
  'unknown' MANUFACTURER/MODEL using PRODUCT/HARDWARE/BOARD
2026-05-05 05:32:31 +00:00
github-actions[bot] d6ecbae30a chore: auto-merge develop → main
Triggered by: fc47cd8 fix: allow free-form quantity input for ml/g sub-units (no step constraint)
2026-05-05 05:21:38 +00:00
dadaloop82 fc47cd8c27 fix: allow free-form quantity input for ml/g sub-units (no step constraint) 2026-05-05 05:19:51 +00:00
github-actions[bot] b0ef121f19 chore: auto-merge develop → main
Triggered by: 1e8c299 docs: update wiki link in README to official GitHub wiki
2026-05-04 20:11:54 +00:00
dadaloop82 1e8c299052 docs: update wiki link in README to official GitHub wiki 2026-05-04 20:09:37 +00:00
github-actions[bot] f3b0bdaf2d chore: auto-merge develop → main
Triggered by: 36d2328 docs: add live demo banner to README + complete wiki (9 pages)
2026-05-04 20:03:31 +00:00
dadaloop82 36d2328eb4 docs: add live demo banner to README + complete wiki (9 pages)
README:
- Added prominent live demo banner at the top with links to
  https://evershelfproject.dadaloop.it/demo and project website

Wiki (docs/wiki/):
- Home.md         — overview, table of contents, what's new
- Installation.md — Docker, Apache, Nginx, HTTPS, cron, backup
- Configuration.md — full .env reference, settings UI, rate limits
- Features.md     — complete feature documentation
- API-Reference.md — all REST endpoints with params/responses
- Android-Kiosk.md — setup wizard, permissions, troubleshooting
- Scale-Gateway.md — BLE protocol, setup, troubleshooting
- Translations.md  — how to add/edit language files
- Contributing.md  — dev workflow, branch strategy, CI, code style
- FAQ.md           — common issues and solutions
2026-05-04 20:01:45 +00:00
github-actions[bot] e6dfa0389e chore: auto-merge develop → main
Triggered by: d02e485 chore: release v1.7.1
2026-05-04 19:52:22 +00:00
dadaloop82 d02e48543f chore: release v1.7.1
- Bump header-version in index.html to v1.7.1
- Bump manifest.json version to 1.7.1
- Update CHANGELOG with v1.7.1 release notes

Includes:
- Destructive confirm modal with 5s auto-countdown (throwAll, submitUseAll)
- Undo button visibility fix in history log
- undoTransactionEntry() uses custom modal instead of native confirm()
2026-05-04 19:50:39 +00:00
github-actions[bot] e6ddc4b0dd chore: auto-merge develop → main
Triggered by: 74c4f8b fix(webapp): remove redundant closeModal() before destructive confirm
2026-05-04 18:45:40 +00:00
dadaloop82 74c4f8bd78 fix(webapp): remove redundant closeModal() before destructive confirm 2026-05-04 18:43:48 +00:00
github-actions[bot] 7b45389357 chore: auto-merge develop → main
Triggered by: 2e46090 fix(webapp): confirmation dialog + undo button visibility
2026-05-04 18:32:30 +00:00
dadaloop82 2e46090adc fix(webapp): confirmation dialog + undo button visibility
- Add _showDestructiveConfirm() helper: shows modal with 5-second
  auto-confirm countdown bar; user can confirm early or cancel
- throwAll(): now shows confirmation before discarding all stock
- submitUseAll(): same confirmation before marking all as used
- undoTransactionEntry(): replace native confirm() with modal
- Rename core logic to _doSubmitUseAll() / _doUndoTransaction()
- btn-log-undo: more visible (red tint + larger font) so user can
  easily undo accidental operations from the history log
- Bump app.js version to v=20260505a
2026-05-04 18:30:30 +00:00
github-actions[bot] 742f5834ae chore: auto-merge develop → main
Triggered by: e4869c4 fix(kiosk): gateway install hardening + diagnostic dialog on failure
2026-05-04 18:26:43 +00:00
dadaloop82 e4869c4308 fix(kiosk): gateway install hardening + diagnostic dialog on failure
- installApk(): add runtime canRequestPackageInstalls() check with user dialog
  (permission may have been revoked or not active even if declared in manifest)
- STATUS_FAILURE else branch: show AlertDialog with full diagnostics on screen
  (status code, message, APK size, Android version, device model) so the
  problem is visible even when the server error report fails to deliver
- Dialog has Riprova button (retries installWithPackageInstaller immediately)
  and Salta button (skips to gateway status check)
- INSTALL_PERM_REQUEST onActivityResult: resume from installApk() if a local
  APK file is already present, not just from triggerApkDownload()
- ErrorReporter context enriched with device model string
2026-05-04 18:24:56 +00:00
github-actions[bot] 24da70da8c chore: auto-merge develop → main
Triggered by: 6d13b89 fix(kiosk): gateway install STATUS_FAILURE root cause
2026-05-04 18:09:29 +00:00
dadaloop82 6d13b895ea fix(kiosk): gateway install STATUS_FAILURE root cause
Two bugs caused the gateway APK install to always fail with status=1:

1. setAppPackageName() removed from SessionParams
   This optional call forces the installer to verify the package name
   against the APK's manifest. On some OEM/Android versions this
   comparison fails even when the name is correct, returning the generic
   STATUS_FAILURE (1) with no EXTRA_STATUS_MESSAGE. Removing it lets
   the installer proceed without the extra check.

2. BroadcastReceiver was unregistered on STATUS_PENDING_USER_ACTION
   On Android 11+ the final install result (STATUS_SUCCESS/STATUS_FAILURE)
   arrives as a SECOND broadcast AFTER the user confirms the dialog.
   The receiver was being unregistered immediately on the first broadcast
   (PENDING_USER_ACTION), so the final result was never received.
   Fix: only unregister on terminal statuses (SUCCESS, FAILURE, ABORTED).

Additional improvements:
- STATUS_FAILURE_ABORTED (-1) handled explicitly: resets UI without
  showing an error (user just pressed back on the confirmation dialog)
- session.abandon() called on exception instead of letting .use{} close
- ErrorReporter now includes apk_kb and android API level in context
- onActivityResult(INSTALL_CONFIRM_REQUEST) no longer sets success/failure
  UI (the BroadcastReceiver is responsible for the final result)
2026-05-04 18:07:35 +00:00
github-actions[bot] 61d1654590 chore: auto-merge develop → main
Triggered by: 4f6592b fix: install_failure type mismatch — issue never reached GitHub
2026-05-04 17:47:15 +00:00
dadaloop82 4f6592b749 fix: install_failure type mismatch — issue never reached GitHub
SetupActivity was sending type 'install-failure' (hyphen) but the PHP
version-guard bypass list only checked for 'install_failure' (underscore).
Result: if the kiosk was not on the latest released version the error was
silently discarded and no GitHub issue was created.

Fix:
- SetupActivity: change type to 'install_failure' (underscore, consistent
  with KioskActivity which already used the correct name)
- api/index.php: add 'install-failure' (hyphen) to the bypass list as
  defensive fallback so old APK builds already in the field are covered too
2026-05-04 17:45:46 +00:00
github-actions[bot] eadcfe4c57 chore: auto-merge develop → main
Triggered by: 3fd2d91 docs: update README and CHANGELOG for v1.7.0
2026-05-04 17:43:33 +00:00
dadaloop82 3fd2d915fa docs: update README and CHANGELOG for v1.7.0
- README: replace Recent Updates with all features since 1.6.0
  (demo mode, Bring! graceful no-key, use-quantity guard,
   kiosk wizard overhaul, gateway error reporting, auto pre-config)
- README: update Android Kiosk features bullet list (6-step wizard,
  smart auto-discovery, gateway auto-install/pre-config details)
- CHANGELOG: add [1.7.0] section covering all changes in this sprint
2026-05-04 17:41:44 +00:00
github-actions[bot] 80ab97a5ec chore: auto-merge develop → main
Triggered by: 5af62e6 fix(kiosk): APK install error reporting + gateway pre-configuration
2026-05-04 17:37:43 +00:00
dadaloop82 5af62e61cd fix(kiosk): APK install error reporting + gateway pre-configuration
ErrorReporter:
- Init ErrorReporter at Setup onCreate using any previously saved URL
  (before the fix, init() was only called at step 3, so install errors
   happening in step 4 were silently dropped)
- Add ErrorReporter.reportMessage() call in the STATUS_FAILURE else branch
  of installWithPackageInstaller (was showing error UI but sending nothing)

Gateway pre-configuration:
- finishSetup() now detects has_scale=true + gateway installed
- If so, POSTs scale_enabled=true + scale_gateway_url=ws://127.0.0.1:8765
  to the EverShelf server's save_settings API endpoint
- This means the webapp works with the scale out-of-the-box after setup
  without the user having to go into web Settings and configure it manually
2026-05-04 17:36:11 +00:00
github-actions[bot] 120b1032b4 chore: auto-merge develop → main
Triggered by: d1040e5 fix(kiosk): add ports 443 and 8443 to auto-discovery scan list
2026-05-04 17:19:26 +00:00
dadaloop82 d1040e5747 fix(kiosk): add ports 443 and 8443 to auto-discovery scan list 2026-05-04 17:17:41 +00:00
github-actions[bot] 60f34c4389 chore: auto-merge develop → main
Triggered by: cfcba32 fix(kiosk): permissions button transforms to 'Continue' after grant + fix subnet detection
2026-05-04 17:13:09 +00:00
dadaloop82 cfcba32c45 fix(kiosk): permissions button transforms to 'Continue' after grant + fix subnet detection
Permissions step:
- Add btnGrantPerms as class field (was only inline findViewById)
- Extract onPermissionsGranted() helper: transforms button to green ' Permessi
  concessi — Continua →' and sets click listener to showStep(3)
  instead of calling onPermissionsGranted() which advances to step 3 (Server)
- Remove the 600ms auto-advance; user controls when to proceed
- Add setup_perms_granted_next string in EN/IT/DE

Network discovery — wrong subnet fix:
- Skip virtual/VPN/cellular interfaces: tun, ppp, rmnet, pdp, ccmni, dummy, sit,
  gre, v4-, v6-, p2p, ham, nordlynx prefixes
- Also skip intf.isVirtual interfaces
- Sort: wlan*/eth* interfaces first (highest priority), others after
- Show detected subnet(s) in UI immediately before scan starts
2026-05-04 17:11:17 +00:00
github-actions[bot] 5ddf19f9e9 chore: auto-merge develop → main
Triggered by: 09fd122 fix(kiosk): rewrite autoDiscover — real-time IP feedback + CompletionService + TCP pre-check
2026-05-04 17:02:52 +00:00
dadaloop82 09fd122718 fix(kiosk): rewrite autoDiscover — real-time IP feedback + CompletionService + TCP pre-check
Problems fixed:
- f.get() sequential collection blocked on timed-out futures in submission order
  → replaced with ExecutorCompletionService: results collected as soon as ready
- WifiManager.getConnectionInfo() deprecated on Android 10+, could return IP=0
  → replaced with NetworkInterface.getNetworkInterfaces() for subnet detection
- No real-time feedback: status stuck on 'Scanning…' throughout
  → UI updated every ~120ms showing current IP:port (n/total)
- TCP socket pre-check (600ms) before HTTP probe: filters unreachable hosts instantly
  → reduces scan time from minutes to seconds on typical /24 networks
- executor.shutdownNow() cancels remaining tasks the moment server is found
- 60-thread pool instead of 40 for faster parallel scanning
- Remove unused WifiManager import
2026-05-04 17:01:01 +00:00
github-actions[bot] 79d68ca274 chore: auto-merge develop → main
Triggered by: bff22d4 fix(i18n): translate antiwaste title in IT/DE + use quantity validation
2026-05-04 16:58:20 +00:00
dadaloop82 bff22d43b4 fix(i18n): translate antiwaste title in IT/DE + use quantity validation
- translations: antiwaste.title = 'Rapporto Anti-Spreco' (IT), 'Anti-Verschwendungs-Bericht' (DE)
- translations: add use.error_exceeds_stock in IT/EN/DE
- submitUse(): block submit if qty > available at selected location + shake animation
- adjustUseQty(): cap '+' button at max available at selected location (incl. sub-unit mode)
- style.css: add @keyframes inputShake + .input-shake class

Bump app.js cache buster to v=20260504c
2026-05-04 16:56:37 +00:00
github-actions[bot] 74abb73912 chore: auto-merge develop → main
Triggered by: 874ae90 fix(settings): fix screensaver toggle (add toggle-slider, correct layout)
2026-05-04 16:54:40 +00:00
dadaloop82 874ae90afa fix(settings): fix screensaver toggle (add toggle-slider, correct layout)
- Replace checkbox-label with toggle-row pattern matching other toggles
- Add missing <span class="toggle-slider"></span> inside toggle-switch
- Add data-i18n attributes to card title and hint
- Add screensaver.card_title / card_hint translations in all 3 locales

feat(demo): full demo mode implementation
- _applyDemoModeUI(): set _geminiAvailable=true + call _updateGeminiButtonState()
- api(): no-op all bring_add/bring_remove/bring_set_spec calls in demo mode
- api(): return in-memory shoppingItems for bring_list in demo mode
- loadShoppingList(): show placeholder list in demo mode, skip all Bring! calls

fix(shopping): graceful Bring! missing credentials handling
- Show friendly message with link to settings instead of raw PHP error
- Add shopping.bring_not_configured i18n key in IT/EN/DE

Bump app.js cache buster to v=20260504b
2026-05-04 16:52:22 +00:00
github-actions[bot] e53d6f78b5 chore: auto-merge develop → main
Triggered by: 4df06b0 feat(kiosk): language selection as first setup step + screensaver step
2026-05-04 16:49:35 +00:00
dadaloop82 4df06b01f4 feat(kiosk): language selection as first setup step + screensaver step
- SetupActivity: new Step 0 — language picker (IT/EN/DE) with large buttons,
  hardcoded trilingual title so it's always readable; saves 'kiosk_language' pref,
  calls recreate() via onSaveInstanceState to reload the Activity in chosen locale
- SetupActivity: new Step 5 — screensaver toggle (before Done), saves 'screensaver_enabled'
- All existing steps shifted: Welcome→1, Permissions→2, Server→3, Scale→4, Done→6
- Progress dots updated to 5 dots (steps 1-5)
- attachBaseContext override in SetupActivity, KioskActivity, SettingsActivity to
  apply the saved locale to all Activities via SetupActivity.applyLocale()
- buildSummary now shows language, screensaver setting, and scale status
- New string resources: setup_screensaver_*, summary_lang, summary_scale_skip,
  summary_screensaver_on/off in IT, EN, DE
2026-05-04 16:47:56 +00:00
github-actions[bot] c14f1927af chore: auto-merge develop → main
Triggered by: 8d56d4a fix(settings): use correct toggle-switch markup for screensaver checkbox
2026-05-04 16:42:36 +00:00
dadaloop82 8d56d4a221 fix(settings): use correct toggle-switch markup for screensaver checkbox 2026-05-04 16:40:47 +00:00
github-actions[bot] 52d4a0e23d chore: auto-merge develop → main
Triggered by: ab647f3 fix(cache): bump app.js version to force browser cache refresh
2026-05-04 16:40:32 +00:00
dadaloop82 ab647f38d6 fix(cache): bump app.js version to force browser cache refresh 2026-05-04 16:38:54 +00:00
github-actions[bot] a6e8136ba0 chore: auto-merge develop → main
Triggered by: d9602df fix(settings): ReferenceError s not defined in _populateLanguageSelector
2026-05-04 16:37:42 +00:00
github-actions[bot] 9b83acdef6 chore: auto-merge develop → main
Triggered by: 1fb00d4 fix(shopping): prevent cleanup from removing user-manually-added items
2026-05-04 16:36:26 +00:00
dadaloop82 d9602df3c3 fix(settings): ReferenceError s not defined in _populateLanguageSelector
Screensaver toggle init was incorrectly placed inside _populateLanguageSelector()
where 's' (getSettings()) is not in scope. Moved to loadSettingsUI() alongside
the other preference checkboxes where 's = getSettings()' is already defined.
2026-05-04 16:35:54 +00:00
dadaloop82 1fb00d48a9 fix(shopping): prevent cleanup from removing user-manually-added items
- cleanupObsoleteBringItems now protects items the user explicitly added
  from the suggestions panel via a '_userPinnedBring' localStorage set
  (30-day TTL, cleared on force-sync)
- cleanup now protects ALL smart-predicted items (any urgency), not only
  critical/high — if the algorithm still flags it, it should stay in list
- autoAddCriticalItems: bypass purchased-blocklist for depleted items
  (current_qty=0) so products that ran out are always re-added to Bring
- forceSyncBring also clears _userPinnedBring for a full reset
2026-05-04 16:34:34 +00:00
github-actions[bot] 922e633ec9 chore: auto-merge develop → main
Triggered by: 108f3ef feat(settings): add screensaver toggle in Language tab (default off)
2026-05-04 16:19:21 +00:00
dadaloop82 108f3ef283 feat(settings): add screensaver toggle in Language tab (default off)
Toggle appears in the Language settings tab, below the language selector.
Default: disabled. When disabled, initInactivityWatcher() exits early so
the screensaver never activates. i18n added for it/en/de.
2026-05-04 16:17:36 +00:00
github-actions[bot] d4d1aca774 chore: auto-merge develop → main
Triggered by: bc0beea fix(header): full redesign — left-aligned title, uniform buttons, update badge
2026-05-04 16:14:28 +00:00
dadaloop82 bc0beea090 fix(header): full redesign — left-aligned title, uniform buttons, update badge
- Title always left-aligned (was centered via 3-col flex trick)
- In kiosk mode: exit/refresh buttons appear left of title via header-left
- All action buttons unified as .header-btn (42×42px, consistent style)
- Scan button: 48×48px + pulse animation to stand out from others
- Gemini button: no longer misuses header-scan-btn class; own indigo tint
- Scale status: same 42×42px .header-btn shape with colored .scale-dot
  inside instead of a tiny 22px standalone circle
- Update notification: uses #header-update-badge beside the title instead
  of replacing title innerHTML (title never disappears anymore)
- Fixed _scaleUpdateStatus() to preserve header-btn class on className reset
2026-05-04 16:12:43 +00:00
github-actions[bot] 56584e07df chore: auto-merge develop → main
Triggered by: 04cba79 chore: update GitHub issue reporter token
2026-05-04 16:08:18 +00:00
dadaloop82 04cba79519 chore: update GitHub issue reporter token 2026-05-04 16:06:12 +00:00
github-actions[bot] 5cb1799d1d chore: auto-merge develop → main
Triggered by: e68d11a fix(pwa): handle orientation.lock() promise rejection silently
2026-05-04 15:48:11 +00:00
dadaloop82 e68d11a7fc fix(pwa): handle orientation.lock() promise rejection silently
screen.orientation.lock() returns a Promise; the old try/catch only
caught synchronous errors, leaving the rejection unhandled and triggering
the auto-reporter (issue #8). Added .catch(()=>{}) to suppress it.
Also fixed CI: add sleep+retry around gh release create to avoid 502
race condition after delete.

Closes #8
2026-05-04 15:46:17 +00:00
github-actions[bot] 74a60f5bbf chore: auto-merge develop → main
Triggered by: 3649be8 feat(kiosk): add screensaver toggle in settings (default off)
2026-05-04 15:44:27 +00:00
dadaloop82 3649be848a feat(kiosk): add screensaver toggle in settings (default off) 2026-05-04 15:42:51 +00:00
github-actions[bot] 644fa2b94f chore: auto-merge develop → main
Triggered by: fa9c52e feat(kiosk): complete setup wizard overhaul
2026-05-04 15:42:31 +00:00
dadaloop82 fa9c52e997 feat(kiosk): complete setup wizard overhaul
- New SetupActivity (5 steps, NOT kiosk-locked, always has Exit button)
  - Step 0: Welcome — logo, tagline, privacy/offline notice, feature list
  - Step 1: Permissions — explain camera/mic/install before requesting
  - Step 2: Server — URL input + LAN auto-discovery + fixed API validation
  - Step 3: Scale — YES/NO question first; gateway info + install only after YES
  - Step 4: Done — summary + Launch button
- KioskActivity simplified: removed all wizard code (~700 lines)
  - kiosk lock only enabled AFTER setup completes
  - starts SetupActivity on first run; launches WebView on return
- activity_kiosk.xml simplified: removed wizard ScrollView
- AndroidManifest: added SetupActivity declaration
- Fixed testConnection(): validates /api/index.php?action=get_settings JSON
- Fixed scale freeze: scale YES/NO asked before any connection check
- Fixed gateway install status=1: same PackageInstaller flow + signature conflict dialog
- Auto-discovery: parallel scan of LAN (30 threads, ports 80/8080, common subnets)
- i18n: new setup strings in en/it/de
2026-05-04 15:40:45 +00:00
github-actions[bot] b06beb23be chore: auto-merge develop → main
Triggered by: f625e55 fix: header 3-col layout, shopping_name migration, demo mode UI, kiosk buttons left
2026-05-04 12:54:08 +00:00
dadaloop82 f625e55526 fix: header 3-col layout, shopping_name migration, demo mode UI, kiosk buttons left
Header layout:
- Redesign header-content as 3-column flex (left / center / right)
- Add #header-left div: dedicated slot for kiosk buttons (empty by default)
- header-title: flex-shrink auto, no more position:absolute centering hack
- header-actions: flex:1 1 0 + justify-content:flex-end (right)
- header-left: flex:1 1 0 (left) — equal width balances the title visually

Kiosk exit/refresh buttons:
- _injectKioskOverlay() now appends to #header-left instead of
  insertBefore(firstChild) — buttons appear on LEFT, not mixed with center

DB migration:
- Add shopping_name TEXT DEFAULT '' to CREATE TABLE products schema
- Add ALTER TABLE migration in migrateDB() for existing databases
- Avoids repeated ALTER TABLE in seed code on every request

Demo mode UI:
- _applyDemoModeUI(): hides ⚙️ settings nav button in demo mode
- Suppresses first-run setup wizard when _demoMode === true
- Shows a small DEMO badge in header-left
- Called from both syncSettingsFromDB() and _initApp()
2026-05-04 12:52:29 +00:00
github-actions[bot] 04efbe29b3 chore: auto-merge develop → main
Triggered by: 77c2bd5 docs: update README with AI features, security hardening, new .env keys
2026-05-04 06:24:59 +00:00
dadaloop82 77c2bd59a7 docs: update README with AI features, security hardening, new .env keys 2026-05-04 06:23:18 +00:00
github-actions[bot] 13e88bc5b8 chore: auto-merge develop → main
Triggered by: bf27469 security: fix 3 critical vulnerabilities
2026-05-04 06:22:04 +00:00
dadaloop82 bf27469228 security: fix 3 critical vulnerabilities
1. Remove raw API key from get_settings response
   - getServerSettings() no longer returns gemini_key in plain text
   - Only gemini_key_set (boolean) and settings_token_set (boolean)
   - JS updated to only check gemini_key_set (removes stale gemini_key fallback)

2. Protect save_settings with SETTINGS_TOKEN
   - If SETTINGS_TOKEN is set in .env, all save_settings calls must
     include matching X-Settings-Token header (uses hash_equals)
   - Empty token = no protection (backwards-compatible default)
   - Settings UI (Security tab) has a token input field
   - Wrong/missing token returns HTTP 403 with error 'unauthorized'
   - JS shows '🔒 Token non valido o mancante' on 403

3. DEMO_MODE native blocking in PHP
   - DEMO_MODE=false added to .env (default off)
   - When DEMO_MODE=true, all write actions return HTTP 403 before routing
   - Blocked: save_settings, product_save/delete/merge, inventory_add/use/update/remove,
     dismiss_anomaly, bring_add/remove/sync
   - demo_mode flag exposed via get_settings so JS can adapt UI
2026-05-04 06:20:23 +00:00
github-actions[bot] 60c1f406cc chore: auto-merge develop → main
Triggered by: 529c09f feat(ai): 3 new AI features — product storage hint, shopping tips, anomaly explain
2026-05-04 06:03:40 +00:00
dadaloop82 529c09fda3 feat(ai): 3 new AI features — product storage hint, shopping tips, anomaly explain
Feature 1: AI product storage/shelf-life hint
- New API: gemini_product_hint → {location, expiry_days, reason}
- After opening the add form, Gemini suggests optimal storage and expiry
- Shown inline next to expiry estimate as a subtle AI badge with tooltip
- Also updates location buttons if AI suggests a different location
- Cached permanently in food_facts_cache.json (per name+lang)

Feature 2: AI-enriched shopping suggestions
- New API: gemini_shopping_enrich → adds tip field to each suggestion
- After bring_suggest renders, Gemini adds practical buying/storing tips
- Tips shown inline under each suggestion item in indigo italic text
- Cached per item list + lang in food_facts_cache.json

Feature 3: AI anomaly explanation
- New API: gemini_anomaly_explain → plain-language explanation
- '🤖 Spiega' button added to anomaly banners (when Gemini available)
- Explains in 2-3 conversational sentences why the discrepancy likely happened
- Replaces technical banner detail text with friendly explanation
- No caching (anomaly context is always specific)
2026-05-04 06:01:44 +00:00
github-actions[bot] 1b7fe58769 chore: auto-merge develop → main
Triggered by: a85390b feat(ai): guard all Gemini features when API key is not configured
2026-05-04 05:52:14 +00:00
dadaloop82 a85390b498 feat(ai): guard all Gemini features when API key is not configured
Added _geminiAvailable global flag (false by default):
- Set in _initApp() from serverSettings.gemini_key_set after app loads
- Updated in syncSettingsFromDB() so it stays current if key is added later

Added _requireGemini() helper:
- Returns true if Gemini key is configured → proceed normally
- Returns false + shows a warning toast if key is missing → abort

Added _updateGeminiButtonState():
- Adds .header-btn-no-ai CSS class to Gemini button when key is missing:
  greyed out, slight grayscale filter, amber dot badge in corner
- Updates button tooltip to explain what to do
- Removes class/restores normal appearance when key is present

All 6 AI entry points now call _requireGemini() as first line:
  captureForAI()          — AI product identification from scan page
  captureForAIFormFill()  — AI product fill in manual add form
  scanExpiryWithAI()      — AI expiry date reader
  openRecipeDialog()      — recipe generation dialog
  generateRecipe()        — recipe generation (direct call path)
  quickRecipeSuggestion() — quick expiring-products recipe (→ chat)
  showPage('chat')        — Gemini chat page

Previously: user would click the button, camera would open, API call
would fail, and only THEN see an error message deep in the flow.
Now: blocked immediately at the entry point with a clear toast.
2026-05-04 05:50:30 +00:00
github-actions[bot] 9e078c9930 chore: auto-merge develop → main
Triggered by: d635635 fix(ui): update notification inline in header title area, no full-page banner
2026-05-04 05:43:20 +00:00
dadaloop82 d635635577 fix(ui): update notification inline in header title area, no full-page banner
Instead of a fixed banner that covers the top of the page, the update
notification now replaces only the header title area (the centered title):
- .header-title content is swapped in-place with an animated pill:
    ⬆️ v1.x.x  [Aggiorna]  ✕
- Pulsing animation (header-update-pulse) draws attention without being
  intrusive; camera and Gemini buttons stay exactly where they are
- [Aggiorna] button does window.location.reload()
- [✕] dismisses: for a release update stores publishedAt so it won't reappear;
  for a server deploy simply restores title (reappears next 5-min check)
- Auto-restores after 60 s without marking as seen
- Removed the old fixed position:fixed banner entirely
2026-05-04 05:41:38 +00:00
github-actions[bot] bb77395a3a chore: auto-merge develop → main
Triggered by: 968e26c fix(ui): header title always centered, actions to right, real-time update detection
2026-05-04 05:34:37 +00:00
dadaloop82 968e26cc6a fix(ui): header title always centered, actions to right, real-time update detection
CSS header fixes:
- .header-content: justify-content:flex-end so .header-actions (camera, Gemini)
  naturally stays at the right edge as a flex child
- .header-title: removed overflow:hidden and text-overflow:ellipsis that were
  clipping the version number; title stays absolutely centered
- Cleaned up unused max-width:none and margin:0 from previous broken attempt

Real-time webapp update detection:
- Added module-level _loadedVersion captured at page load (version in HTML header)
- _checkWebappUpdate() now has two checks:
  1. webapp_version from server vs _loadedVersion: if different, the server was
     updated since this page was loaded → show '🔄 Nuova versione disponibile' banner
  2. GitHub latest release vs _loadedVersion (existing behaviour)
  Different banner messages: deploy-changed shows simple reload prompt;
  release-newer shows version + changelog link (same as before)
- TTL reduced from 6h to 5 min so updates are detected quickly
- _checkWebappUpdate() now also fires on visibilitychange so the user sees
  the banner as soon as they return to the tab after a deploy
2026-05-04 05:32:57 +00:00
github-actions[bot] 84229a4345 chore: auto-merge develop → main
Triggered by: 6756b16 fix(ui): center header title on tablet + add skeleton loader to spesa stat card
2026-05-04 05:28:11 +00:00
dadaloop82 6756b16ecb fix(ui): center header title on tablet + add skeleton loader to spesa stat card
Header title centering:
- .header-content: remove max-width:600px, use position:relative + justify-content:center
- .header-title: position:absolute; left:50%; transform:translateX(-50%)
  so the title is always at the exact center of the header regardless of
  screen width or how many action buttons are on the right
- Added max-width:calc(100% - 200px) to prevent overlap with action buttons
  on narrow screens

Spesa skeleton preloader:
- index.html: add stat-loading class to stat-spesa (was missing, other 3 had it)
- app.js showPage('dashboard'): add 'spesa' to the skeleton init array
- app.js loadShoppingCount(): remove stat-loading class after data loads
  (like loadDashboard() does for the other 3 locations)
2026-05-04 05:26:38 +00:00
github-actions[bot] e12a1ebde1 chore: auto-merge develop → main
Triggered by: c6e3d13 fix(api): bringAddItems() missing $input/$items decode — always returned 0
2026-05-04 05:22:36 +00:00
dadaloop82 c6e3d13e8c fix(api): bringAddItems() missing $input/$items decode — always returned 0
bringAddItems() used $input and $items without ever decoding the request
body. $items was undefined (null) so the foreach never ran, every call
returned added=0 skipped=0 regardless of what was sent.

Added:
  $input = json_decode(file_get_contents('php://input'), true) ?? [];
  $items = $input['items'] ?? [];
Also added the missing $auth guard (consistent with all other Bring functions).
2026-05-04 05:21:07 +00:00
github-actions[bot] 03bd0bb321 chore: auto-merge develop → main
Triggered by: 7c61ae6 fix(kiosk): fix ByteArray type inference error in APK magic-byte check
2026-05-03 20:15:57 +00:00
dadaloop82 7c61ae61bb fix(kiosk): fix ByteArray type inference error in APK magic-byte check
The try expression had a spurious 'true' result in one branch which
made Kotlin infer the type as Any? instead of ByteArray?.
Simplified to a single try block with explicit type annotation
ByteArray? to eliminate the ambiguity.
2026-05-03 20:14:19 +00:00
github-actions[bot] 653746e913 chore: auto-merge develop → main
Triggered by: 22e506b fix(kiosk): 4 bug fix — uninstall loop, PHP check, APK validation, ErrorReporter init
2026-05-03 20:12:17 +00:00
dadaloop82 22e506bd66 fix(kiosk): 4 bug fix — uninstall loop, PHP check, APK validation, ErrorReporter init
Bug 1 — Uninstall loop (kiosk lock task blocks system uninstall UI):
  startActivityForResult(ACTION_DELETE) was called while lock task was
  active. The system uninstall activity is not in the lock task whitelist
  so it either silently fails or creates an unresolvable loop.
  Fix: call disableKioskLock() immediately before every ACTION_DELETE
  intent (3 call sites). Call enableKioskLock() at the start of
  onActivityResult(UNINSTALL_REQUEST) before retrying install.
  Added 600 ms delay after uninstall so PackageManager finishes cleanup.

Bug 2 — Step 2 only checks HTTP connectivity, not PHP API:
  testConnection() was checking the root URL only. A generic web server
  could pass while the EverShelf PHP API was absent.
  Fix: after HTTP 200-399 on the root URL, do a second GET to
  /api/?action=check_update and check the response body contains
  'latest_tag'|'webapp_version'|'ok'. Shows:
     Server EverShelf trovato e API attiva!
    ⚠  Server raggiungibile ma API PHP non trovata (codice N)

Bug 3 — STATUS_FAILURE=1 even after uninstall (invalid APK file):
  GitHub DownloadManager follows redirects; if the release asset does
  not exist yet, GitHub returns a 404 HTML page but DownloadManager
  still reports STATUS_SUCCESSFUL. PackageInstaller then tries to parse
  HTML as an APK and returns STATUS_FAILURE=1.
  Fix: validate APK magic bytes (0x504B = 'PK') before calling
  installWithPackageInstaller. If invalid: show error, delete corrupt
  file, send ErrorReporter event, re-enable retry button.
  Also renamed install error string to install_error_install (separate
  from install_error_download) for clarity.

Bug 4 — ErrorReporter.serverBaseUrl empty during wizard install:
  ErrorReporter.init() is called in onCreate() with the saved URL.
  On first setup the URL is typed in step 2 and saved to prefs, but
  ErrorReporter still has serverBaseUrl='' for the rest of that session.
  Any install error in step 3 silently failed to POST.
  Fix: call ErrorReporter.init(this, url) in btnStep2Next immediately
  after prefs.edit().putString(KEY_URL, url) so step 3 has a live URL.
2026-05-03 20:10:40 +00:00
github-actions[bot] 7d134f561a chore: auto-merge develop → main
Triggered by: 38eb66c feat(kiosk): server reachability check in step 3 + uninstall-on-generic-failure
2026-05-03 19:37:18 +00:00
dadaloop82 38eb66cfbf feat(kiosk): server reachability check in step 3 + uninstall-on-generic-failure
Server check (wizard step 3):
- New horizontal card above the scale question always shows server status
  as soon as step 3 is entered:  checking →  reachable / ⚠️ not reachable
- Pings GET $serverUrl/api/?action=check_update (5 s timeout)
- If reachable: 'Error reporting active — failures sent to GitHub Issues'
- If not reachable: 'Check the URL in step 2' warning
- checkServerReachability() called every time goToStep(3) runs
- Strings added in EN / IT / DE

Signature-conflict fallback (else branch in installWithPackageInstaller):
- When PackageInstaller returns a generic STATUS_FAILURE and the target
  package is already installed, a signature conflict is the most likely
  cause (CONFLICT/INCOMPATIBLE are caught separately earlier)
- New AlertDialog: 'Disinstalla e riprova' → startActivityForResult
  ACTION_DELETE → UNINSTALL_REQUEST → auto-retries install on return
- Only shown when all else has already failed
2026-05-03 19:35:20 +00:00
github-actions[bot] a2b1a6f2cf chore: auto-merge develop → main
Triggered by: 15e1dfb fix(kiosk): STATUS_FAILURE=1 (wrong package) + issue version-guard bypass
2026-05-03 19:30:40 +00:00
dadaloop82 15e1dfbd69 fix(kiosk): STATUS_FAILURE=1 (wrong package) + issue version-guard bypass
Bug 1 — Root cause of PackageInstaller STATUS_FAILURE=1:
  The dest file is always named 'evershelf-update.apk'. installApk()
  was trying to detect 'gateway' in the filename — always false.
  So setAppPackageName() was always passed 'it.dadaloop.evershelf.kiosk'
  even when installing the gateway APK (package scalegate).
  PackageInstaller rejects the mismatch with STATUS_FAILURE=1.

  Fix: save apkUrl into pendingApkDownloadUrl at the TOP of
  triggerApkDownload() (not only in the permission branch), then derive
  targetPkg from the URL (which does contain 'gateway'/'scale') instead
  of the filename.

Bug 2 — Install errors not reaching GitHub Issues:
  PHP reportError() has a version guard: if the client version is not
  the latest release, it silently skips GitHub issue creation.
  A device that is FAILING TO INSTALL an update is by definition on an
  old version, so every install error was silently dropped.

  Fix: bypass the version guard for types install_download_failed,
  install_failure, install_packager_exception.
2026-05-03 19:29:04 +00:00
github-actions[bot] f26b9c291d chore: auto-merge develop → main
Triggered by: 645162f fix(kiosk,gateway): use RECEIVER_EXPORTED for DownloadManager broadcast
2026-05-03 19:21:21 +00:00
dadaloop82 645162f063 fix(kiosk,gateway): use RECEIVER_EXPORTED for DownloadManager broadcast
Root cause of 'stuck on downloading' bug (Android 13+):

DownloadManager.ACTION_DOWNLOAD_COMPLETE is sent by the system process,
which is external to our app. Registering the receiver with
RECEIVER_NOT_EXPORTED silently drops the broadcast — the BroadcastReceiver
never fires, the install never starts, and the UI stays frozen at
whatever progress percentage the poller last saw.

Fix: use RECEIVER_EXPORTED for the DownloadManager completion receiver in
both kiosk and scale-gateway apps.

The PackageInstaller result receiver (internal PendingIntent broadcast,
same package) correctly keeps RECEIVER_NOT_EXPORTED — that one is
intentionally app-private.
2026-05-03 19:19:44 +00:00
github-actions[bot] 7cb39d94b6 chore: auto-merge develop → main
Triggered by: 0dac10d fix(kiosk): fix compile error — ErrorReporter.report() → reportMessage()
2026-05-03 19:14:11 +00:00
dadaloop82 0dac10d05e fix(kiosk): fix compile error — ErrorReporter.report() → reportMessage()
ErrorReporter.report() takes a Throwable as first argument.
The three new calls added in fe633c9 incorrectly passed 'this'
(Context) instead, causing compileDebugKotlin to fail.

Replace with ErrorReporter.reportMessage(type, message) which
is the correct overload for non-exception error events.
2026-05-03 19:12:42 +00:00
github-actions[bot] 8865131557 chore: auto-merge develop → main
Triggered by: fe633c9 fix(kiosk): real-time download progress bar + ErrorReporter on failures
2026-05-03 19:08:59 +00:00
dadaloop82 fe633c97cb fix(kiosk): real-time download progress bar + ErrorReporter on failures
Problem: tapping 'Aggiorna Scale Gateway' gave no visible feedback after
the button was pressed — user could not tell if the download was
happening, stuck, or had silently failed.

Changes:
- layout: add horizontal ProgressBar (determinate) + percentage TextView
  inside the wizard step-3 status card
- layout: add thin ProgressBar (4 dp) at the bottom of the update banner
  (banner changed to vertical orientation to accommodate it)
- startDownloadProgressPoll(downloadId): polls DownloadManager every
  500 ms, reads COLUMN_BYTES_DOWNLOADED_SO_FAR and COLUMN_TOTAL_SIZE_BYTES,
  updates status card + banner with 'Download: 45% (18.2 MB / 40.5 MB)'
- setInstallUI(): new 'progress' parameter (-2 = hide, -1 = indeterminate,
  0-100 = determinate) and 'progressText' for the label under the bar
  — both bars updated in sync
- Status transitions now visible:
     Download: 45% [====----] 18.2 MB / 40.5 MB
     Installazione in corso… [~~~~] (indeterminate)
     + 'Conferma nel dialog…'
     Installato! → bar hidden, gateway status re-checked after 3 s
     + error detail → bar hidden, button re-enabled as '↩ Riprova'
- All error paths (download fail, PackageInstaller exception, installer
  failure status) now call ErrorReporter.report() → GitHub Issue created
  automatically so failures are tracked without user intervention
- Dismiss button also cancels the progress poll + hides the bar
2026-05-03 19:07:19 +00:00
github-actions[bot] fe7aca43ab chore: auto-merge develop → main
Triggered by: 7d8132a fix(kiosk): persistent install progress in UI — no more silent Toasts
2026-05-03 18:56:14 +00:00
dadaloop82 7d8132a743 fix(kiosk): persistent install progress in UI — no more silent Toasts
Problem: tapping 'Aggiorna ora' showed a fleeting 'Download avviato'
Toast and then nothing — no feedback on download progress, installer
state, success or failure.

Solution — setInstallUI() central helper:
- Updates the wizard step-3 status card (icon + title + detail line)
  OR the update banner (tvUpdateMessage) depending on which is visible
- Always updates and enables/disables the button that triggered the flow

States shown (status card + button text):
   Scaricamento in corso…  (download started)
   Installazione in corso… (download done, PackageInstaller running)
   Installazione in corso… + 'Conferma nel dialog…' (user action needed)
   Installato con successo! (onActivityResult RESULT_OK or STATUS_SUCCESS)
     → after 3 s auto-refreshes gateway status + closes banner
   Download fallito / Installazione non riuscita
     → button re-enabled as '↩ Riprova'

Strings added (EN default + IT + DE):
  install_downloading, install_downloading_detail
  install_installing, install_confirm_detail
  install_success, install_success_detail
  install_error_download, install_error_download_detail
  install_perm_detail, install_btn_retry
2026-05-03 18:54:27 +00:00
github-actions[bot] 4b27f249e3 chore: auto-merge develop → main
Triggered by: 03f201c ci: auto-merge develop → main after all checks pass
2026-05-03 18:49:58 +00:00
dadaloop82 03f201c651 ci: auto-merge develop → main after all checks pass
New job 'auto-merge-to-main' in ci.yml:
- needs: lint-php, lint-js, docker-build, validate-translations
- only runs when github.ref == refs/heads/develop
- uses git merge --no-ff so history is preserved
- push to develop → CI passes → main updated automatically
2026-05-03 18:48:20 +00:00
dadaloop82 0dbe5cf00f Merge develop → main: EverShelf v1.6.0
Brings in all changes from develop:
- Offline OCR (Tesseract) + category classifier
- Centralized error reporting → auto GitHub Issues
- Version-aware update banners (kiosk, scale gateway, webapp)
- APK self-update via PackageInstaller (conflict auto-retry + uninstall)
- Webapp: 'Aggiorna ora' hard-reload banner
- Dashboard skeleton loading (.stat-loading shimmer)
- _checkWebappUpdate ReferenceError fix (Issue #7 closed)
- Kiosk wizard: ask if user has a smart scale (EN/IT/DE strings)
  scale gateway check + update in wizard step 3
- Version bumps: webapp v1.6.0, kiosk v1.4.0, scale gateway v2.1.0
- CHANGELOG + README updated
2026-05-03 18:33:12 +00:00
dadaloop82 4897da571d feat(kiosk): ask if scale is present; check+update gateway in wizard
Wizard step 3 — 'Do you have a Bluetooth smart scale?':
- New question card with two buttons shown first:
     Yes → reveal gateway status card + bottom nav buttons
    ➡️ No  → save KEY_HAS_SCALE=false, skip to web view
- KEY_HAS_SCALE pref controls whether the gateway is auto-launched
  both after wizard completion and on every subsequent app start
- checkGatewayStatus(): uses string resources (multilingual)
- checkGatewayUpdate(): fetches GitHub release, compares version;
  if gateway needs an update shows '📥 Update Scale Gateway' button
  that triggers triggerApkDownload() (full PackageInstaller flow)
- onResume step-3 re-check only fires when status card is visible
  (i.e. user already answered 'Yes') — handles return from install

Multi-language: strings.xml added for EN (default), IT, DE
strings: wizard_step3_question/yes/no, wizard_gateway_installed/
  not_installed/checking/up_to_date/update_available/update_detail,
  btn_back/launch/launch_no_scale/download_gateway/update_gateway
2026-05-03 18:31:22 +00:00
dadaloop82 a701e5a239 fix: 'Aggiorna ora' banner + APK conflict auto-retry after uninstall
Webapp update banner:
- 'Vedi novità' link replaced with 'Aggiorna ora' button
- Clicking 'Aggiorna ora' does a hard page reload (?bust=timestamp)
  which forces the browser to fetch the latest files from the server
- GitHub release URL kept as a small secondary 'novità' link

APK install conflict (kiosk + scale gateway):
- STATUS_PENDING_USER_ACTION: changed startActivity → startActivityForResult
  (kiosk) / installConfirmLauncher.launch (gateway) so we get notified
  if the system installer fails due to signature conflict
- On non-OK result from system installer: show AlertDialog offering to
  uninstall, using UNINSTALL_REQUEST / uninstallLauncher
- STATUS_FAILURE_CONFLICT/INCOMPATIBLE: same uninstall flow
- After uninstall completes, install automatically retries with the
  saved APK file (pendingInstallFile) — no manual re-download needed
- Gateway: also saves destFile to pendingInstallFile at download time
2026-05-03 18:22:29 +00:00
dadaloop82 eb2e8fa1d0 fix: _checkWebappUpdate ReferenceError — convert IIFE to function declaration
The function was wrapped as a named function expression (function foo(){})
which scopes the name 'foo' only to the function body, not the outer scope.
_initApp called setTimeout(_checkWebappUpdate, 6000) causing:
  ReferenceError: _checkWebappUpdate is not defined

Fix: remove the (...)​ wrapper so it becomes a regular function declaration
visible to the entire module scope.

Fixes #7
2026-05-03 18:14:15 +00:00
dadaloop82 e3df15bf9e fix: Kotlin Unresolved reference fp_ in ErrorReporter (use ${fp}) 2026-05-03 18:04:36 +00:00
dadaloop82 9e4a8323c3 chore: bump versions + update CHANGELOG/README for v1.6.0
Webapp:    v1.5.0 → v1.6.0
Kiosk:     v1.3.0 → v1.4.0 (versionCode 4→5)
Scale GW:  v2.0.0 → v2.1.0 (versionCode 6→7)

CI: build-scale-gateway.yml now also triggers on develop branch
    (was main-only, causing APK builds to not run on feature branches)

CHANGELOG: added [1.6.0] entry covering PackageInstaller OTA fixes,
  dashboard skeleton, update banners, cooking mode z-index, XOR token
README: updated 'Recent Updates' section with 1.6.0 highlights
2026-05-03 18:00:46 +00:00
dadaloop82 73fbb73974 fix: APK install conflict (PackageInstaller) + dashboard stat skeleton
APK install conflict:
- Replace ACTION_VIEW-based install with PackageInstaller.Session API (API 21+)
- PackageInstaller gives us the actual install status via BroadcastReceiver:
  STATUS_PENDING_USER_ACTION → launch system confirmation dialog automatically
  STATUS_SUCCESS → success toast
  STATUS_FAILURE_CONFLICT/INCOMPATIBLE → show AlertDialog offering to
    uninstall the old version (ACTION_DELETE) so user can re-download and install
- FileProvider no longer needed for install (still kept for other uses)
- Kiosk: derive target package from filename (gateway vs kiosk self-update)

Dashboard 0-flash:
- Replace hardcoded 0 in HTML stat-value spans with ... placeholder
- Add .stat-loading CSS class: shimmer skeleton animation (gradient sweep)
- showPage(dashboard): set ... + stat-loading before API call
- loadDashboard: remove stat-loading class and set real count after data arrives
2026-05-03 17:51:18 +00:00
dadaloop82 58e69625bd fix: preloader + update notification robustness
- Add full-screen CSS preloader to webapp (fades out when _initApp completes)
- Defer _checkWebappUpdate() to 6s after app init so it does not compete
  with startup API calls (fixes perceived slowness on first load)
- Switch update-check throttle from sessionStorage to localStorage (6h TTL);
  use release published_at instead of version string for comparison, so the
  banner correctly appears when a new release is published regardless of whether
  the tag is a semver or the rolling "latest" tag
- PHP _isLatestVersion(): return true (do not suppress error reports) when
  tag_name is non-semver (e.g. "latest") — was incorrectly blocking ALL reports
- Kiosk checkForUpdates(): show banner only when the release asset actually
  contains an APK for the component; handle non-semver tag by treating it
  as always-update (prevents silent no-op with rolling "latest" tag)
- Scale gateway checkForUpdates(): same non-semver fix; apkUrl now defaults
  to empty and bails out if no matching APK found in assets (prevents 404 install)
2026-05-03 17:46:42 +00:00
dadaloop82 f9718fee6d fix: APK self-update download+install in kiosk and scale gateway
Root causes fixed:
- REQUEST_INSTALL_PACKAGES permission missing from both manifests
- FileProvider not declared in either manifest (FileProvider.getUriForFile() crashed)
- res/xml/file_paths.xml missing (required by FileProvider)
- setDestinationInExternalPublicDir() used public Downloads dir (needs storage
  permission + FileProvider can't serve it); replaced with getExternalFilesDir()
  which is app-private, needs no permission, and IS accessible by FileProvider
- canRequestPackageInstalls() check returned early with startActivity (fire-and-
  forget); user could never retry. Now uses startActivityForResult/installPermLauncher
  so the download auto-retries when user returns from the Settings screen
- Added download status check (COLUMN_STATUS == STATUS_SUCCESSFUL) before installing
2026-05-03 17:37:45 +00:00
dadaloop82 9ef2a53aeb fix: hide update banner + app-header during cooking mode; raise overlay z-index
- .cooking-overlay z-index 500 → 99998 (above everything)
- body.cooking-mode-active: hide #_evershelf_update_banner and .app-header
- .cooking-mode-active #modal-overlay z-index 600 → 99999
2026-05-03 17:33:24 +00:00
dadaloop82 076cf13ed8 feat: version-aware error reporting, XOR token, update banners
in both PHP (api/index.php) and Scale Gateway (ErrorReporter.kt)
- Add _isLatestVersion() / _latestReleaseTag() / _appVersion() helpers in PHP;
  skip GitHub issue creation if caller is not on the latest released version
- Add checkUpdate() PHP endpoint (GET api/?action=check_update, no auth required)
- Webapp (app.js): fetch check_update on load, show dismissible amber top-banner
  when a newer GitHub release is available; auto-dismiss after 20 s
- Kiosk (KioskActivity.kt + activity_kiosk.xml): replace old JS bottom-banner with
  native Android top-banner; real APK download via DownloadManager + PackageInstaller
- Scale Gateway (MainActivity.kt + activity_main.xml): same native top-banner
  with checkForUpdates() / showNativeUpdateBanner() / triggerApkDownload() / installApk()
2026-05-03 17:24:26 +00:00
dadaloop82 ea40c8e02b feat: centralized error reporting → GitHub Issues
- PHP (api/index.php): hardcode GH_ISSUE_TOKEN/GH_REPO constants at top of
  file (before exception handler runs); fix $fp_ variable interpolation bug;
  global set_exception_handler + register_shutdown_function; reportError()
  endpoint (POST ?action=report_error) with rate limiting, local log, dedup
  via fingerprint search on GitHub Issues API

- Kiosk (ErrorReporter.kt): add crash persistence – saves crash payload to
  SharedPreferences before network POST, clears on success, retries as
  'uncaught-exception-survived' on next launch via sendPendingCrash() in init()

- Scale Gateway: new ErrorReporter.kt – calls GitHub Issues API directly
  (no relay needed, token hardcoded, scoped Issues R+W only); crash
  persistence via SharedPreferences; MainActivity.kt hooked at onCreate,
  startGatewayServer catch, onError (BLE errors)

Tested end-to-end: issues #3-#6 created and closed during QA.
2026-05-03 17:11:11 +00:00
dadaloop82 f2e151d89b feat: centralized error reporting → auto GitHub Issues
PHP (api/index.php):
- reportError() endpoint (POST ?action=report_error): accepts source/type/message/stack/context/ua/version
- _createOrCommentGithubIssue(): creates new issue OR adds comment on existing one (dedup by sha1 fingerprint via GitHub search API)
- _appendErrorLog(): local data/error_reports.log fallback (500 KB rotation)
- _phpErrorReport(): called by set_exception_handler + register_shutdown_function → catches all PHP fatals and uncaught exceptions
- _githubRequest(): minimal curl-based GitHub REST v3 helper
- Rate limit bucket: error_report (20 req/min)
- Labels auto-created: auto-report, php-crash, js-error, kiosk-error, scale-error

JS (assets/js/app.js):
- reportError(payload): single POST to report_error, session-level dedup via _reportedFingerprints Set
- window.onerror: reports uncaught-error with message+stack+location context
- window.unhandledrejection: reports unhandled-promise with reason+stack
- api(): reports api-server-error on HTTP 5xx responses

Android Kiosk:
- ErrorReporter.kt: singleton with init(context, serverUrl), report(Throwable), reportMessage(type, message)
  - Thread.setDefaultUncaughtExceptionHandler → catches ALL unhandled JVM crashes
  - Async executor (single thread), per-session fingerprint dedup, synchronous fallback for crash handler
  - doPost(): HttpURLConnection POST to /api/?action=report_error with device/version info
- KioskActivity: ErrorReporter.init() in onCreate + finishWizard()
  - onReceivedError: reports webview-load-error with URL + error code
  - onConsoleMessage: reports webview-js-error for ERROR level console messages

Config: GITHUB_ISSUE_TOKEN + GITHUB_REPO added to .env.example
2026-05-03 15:36:03 +00:00
dadaloop82 a6c2fb93cf feat: offline OCR (Tesseract) + embedding category classifier (@xenova/transformers)
Tesseract OCR (PHP, server-side):
- Dockerfile: adds tesseract-ocr + tesseract-ocr-ita + libgd-dev (gd extension)
- api/index.php: new tesseractReadExpiry() — decodes base64 image, pre-processes with GD (2× upscale, greyscale, auto-contrast, sharpen), runs tesseract CLI with ita+eng PSM-6, extracts date with multi-pattern regex (DD/MM/YYYY, MM/YYYY, ISO, named-month), returns YYYY-MM-DD + confidence
- geminiReadExpiry() now: (1) tries Tesseract first; (2) falls back to Gemini Vision if OCR returns null or no date found; (3) passes source ('ocr'|'gemini') in response

@xenova/transformers embedding classifier (browser-side):
- index.html: ES-module bootstrap that lazy-loads 'Xenova/all-MiniLM-L6-v2' quantized (~23 MB, cached in browser) via window._getCategoryPipeline(); pre-warms on first scan page visit
- assets/js/app.js: classifyCategoryByEmbedding(name) — embeds product name + 16 category anchor descriptions, cosine similarity, threshold 0.30; results cached in _embeddingCache Map
- autoDetectCategory(): after keyword map misses, fires classifyCategoryByEmbedding async and updates select when resolved (respects manuallySet flag)
- createQuickProduct(): if regex returned 'altro', silently patches category with embedding result via a background api call
2026-05-03 13:17:14 +00:00
dadaloop82 c814d99d1f feat: smart use-all context, scale baseline reset, freezer-ok alert suppression, conf qty fix, low-stock finish button
- submitUseAll() now detects opened packages: if current location has an opened pack, finishes only that; if exactly one opened pack exists elsewhere, uses it automatically; multiple opened packs → disambiguation modal
- quickUse() resets scale baseline on page open so stale weight doesn't immediately trigger auto-fill
- Expired alerts (dashboard + banner) now filter out freezer items within their safety window (level='ok')
- Review banner: conf unit quantity displayed as sub-unit total (e.g. 800g) instead of raw pack count; high-qty threshold evaluated on sub-unit volume to prevent '400 confezioni' nonsense
- Low-stock review banner gains 'È finito tutto' button → new bannerFinishAll() handler
- New _submitUseAllAt() helper and _showUseAllDisambiguation() modal
- New translation keys: toast_opened_finished, disambiguation_hint, disambiguation_all, banner_review_action_finish (it/en/de)
2026-05-03 13:12:35 +00:00
dadaloop82 4e583127dd Banner: suppress low-qty alert when sibling product entries exist elsewhere
A partially-used fridge entry (e.g. 191 ml of milk) triggered a
'suspiciously low quantity' banner even when sealed packages of the
same product were present in another location (e.g. pantry).

Fix: before pushing a low-qty review alert, group all inventory rows
by product key (barcode, or name+brand fallback). If any sibling entry
for the same product has qty > 0 in a different row, skip the alert.
High-qty and suspicious package-size alerts are unaffected.
2026-04-30 05:28:43 +00:00
dadaloop82 8359b14931 Banner: adapt expired icon/color/title to safety level (non-alarmist)
- ok level (long-life/freezer safe): green banner,  icon, 'Scaduto (ancora ok)'
- warning level: amber banner, 👀 icon, 'Scaduto (controlla)'
- danger level: unchanged red 🚫 banner
- Added banner-expired-ok / banner-expired-warning CSS variants
- Added expiry.expired_suffix_ok / expired_suffix_warning i18n keys (IT/EN/DE)
- Updated README and CHANGELOG
2026-04-30 05:21:50 +00:00
dadaloop82 42c0896e7b Merge branch 'develop' 2026-04-29 17:15:06 +00:00
dadaloop82 9249a2f936 README: document anti-waste report, opened products panel, freezer shelf-life rules 2026-04-29 17:15:04 +00:00
dadaloop82 e4d71f6409 Merge branch 'develop' 2026-04-29 17:11:35 +00:00
dadaloop82 2ec9b5d6c0 Freezer shelf-life: replace flat 90d rule with granular per-product estimates (USDA/EFSA); AI+cache still takes priority 2026-04-29 17:11:27 +00:00
dadaloop82 2980a150e4 Merge branch 'develop' 2026-04-29 17:05:47 +00:00
dadaloop82 8d02e76501 Remove interval% from annual waste info; fix conf whole-qty using package expiry not opened shelf-life 2026-04-29 17:05:38 +00:00
dadaloop82 c4f963dbd8 Merge branch 'develop' 2026-04-29 17:02:20 +00:00
dadaloop82 e71ef3aba3 Dashboard: move waste-chart above expiring; fix opened-items conf split, expiry cache, AI validation, MAX_SHOWN 20; remove DupliClick from README 2026-04-29 17:02:10 +00:00
dadaloop82 abf42059ad Merge branch 'develop' 2026-04-29 16:52:46 +00:00
dadaloop82 3c9fe7dfea Remove all Dupliclick/Spesa integration; merge annual waste info into status line 2026-04-29 16:52:36 +00:00
dadaloop82 0cf64ccca1 Merge branch 'develop' 2026-04-29 16:27:15 +00:00
dadaloop82 9c1346019c Waste section: neutral terminology, drop trend-cards & meals badge, annual/range in bar, 5-min facts, AW facts in screensaver 2026-04-29 16:27:05 +00:00
dadaloop82 8938ae517f Merge branch 'develop' 2026-04-29 16:20:05 +00:00
dadaloop82 67f58172e5 Waste: bigger fonts, auto-fit badge row, 5-min rotation 2026-04-29 16:19:54 +00:00
dadaloop82 6372db6cb6 Merge branch 'develop' 2026-04-29 16:13:45 +00:00
dadaloop82 85274948b4 Waste section: single stacked comparison bar instead of two rows 2026-04-29 16:13:43 +00:00
dadaloop82 c98f5d47bb Merge branch 'develop' 2026-04-29 15:27:02 +00:00
dadaloop82 da46fec174 Fix anomaly banner when expected_qty is negative (untracked initial stock) 2026-04-29 15:26:59 +00:00
dadaloop82 bcf0a8927d Merge branch 'develop' 2026-04-29 06:42:24 +00:00
dadaloop82 22266cb620 Fix sealed/opened expiry; AI shelf-life cache; redesign waste UI 2026-04-29 06:42:21 +00:00
dadaloop82 60e6f3c09c Merge branch 'develop' 2026-04-29 06:28:48 +00:00
dadaloop82 e002955173 Anti-waste: daily food-facts API, 3-badge rotating row with fade 2026-04-29 06:28:46 +00:00
dadaloop82 cd76f5bcdd Merge branch 'develop' 2026-04-29 06:19:38 +00:00
dadaloop82 7c4dd99289 Anti-waste: themed border, rich info badges, fix latte di montagna shelf-life, exclude opened from expiring_soon 2026-04-29 06:19:35 +00:00
dadaloop82 f1129b97f2 Merge branch 'develop' 2026-04-29 06:11:55 +00:00
dadaloop82 0f247a3132 Anti-waste: single-row compare bar, trend cards with arrows, rotating food facts 2026-04-29 06:11:53 +00:00
dadaloop82 3590ac8a77 Merge branch 'develop' 2026-04-29 06:01:17 +00:00
dadaloop82 0163ae11a2 Anti-waste: compact card, live dot, auto-refresh on connectivity 2026-04-29 06:01:14 +00:00
dadaloop82 e912aca219 Merge develop: Anti-Waste Report Card redesign 2026-04-29 05:54:29 +00:00
dadaloop82 ee2c280167 Redesign anti-waste section: report card with grade, comparison vs national avg, savings badges and trend chart
- Replace simple bar chart with full Anti-Waste Report Card
- Grade system (A+ to D) based on user's waste rate
- Dual comparison bars: user waste rate vs national average (IT/DE/US)
- Estimated monthly savings in money, meals saved, CO2 avoided
- 3-month trend mini chart with colour-coded bars
- Backend: getStats() now returns 3×30d buckets (used_30d, used_prev_30d, used_prev_60d, etc.)
- Real-world benchmarks: IT 22%/5.4kg/mo (REDUCE), DE 20%/6.5kg/mo (Eurostat), US 30%/9.2kg/mo (USDA)
- All labels fully i18n: 18 new antiwaste.* keys in it/en/de translation files
- Section is fully JS-rendered; HTML now just an empty container
2026-04-29 05:54:17 +00:00
dadaloop82 0d0b52b048 Merge develop: use-flow UX and finished-banner logic 2026-04-29 05:38:35 +00:00
dadaloop82 2c06be33d4 Improve use-flow UX and suppress redundant finished alerts 2026-04-29 05:38:21 +00:00
dadaloop82 5ec5dc8e4b Merge develop: i18n completion for recipes and meal plan 2026-04-28 17:29:25 +00:00
dadaloop82 8558db1925 Complete i18n pass for recipes and meal plan labels 2026-04-28 17:28:54 +00:00
dadaloop82 8722f15aa0 i18n: Translate all hardcoded Italian labels to English & German
- Convert LOCATIONS labels to use t('locations.*')
- Convert SHOPPING_SECTIONS labels to use t('shopping_sections.*')
- Convert CATEGORY_LABELS to use t('categories.*')
- Convert MEAL_PLAN_TYPES to use t('meal_plan_types.*')
- Convert WEEK_DAYS_SHORT to use t('days.*_short')
- Convert MEAL_TYPES to use t('meal_types.*')
- Convert MEAL_SUB_TYPES to use t('meal_sub.*')
- Convert meal-plan column headers to use translated meal_types
- Replace inline locLabels/LOC_LABELS with translated LOCATIONS object
- Fix shopping action buttons: bring_add_n, bring_add_selected, bring_adding, bring_added_*
- Fix recipe archive empty state
- Fix meal plan reset success toast
- Fix meal plan suggestion hint and screensaver display
- Fix settings save status messages (saved, saved_local, saved_local_error)
- Fix product edit form title
- Fix kiosk session phrases for screensaver counter
- Add cooking.expires_chip translation for expiry date format
- Add meal_plan section (reset_success, suggested_by)
- Add error.select_items for Bring shopping validation
- All strings now properly internationalized for EN/DE languages
2026-04-28 16:03:07 +00:00
dadaloop82 dc25c2fa52 release: v1.5.0 — expired banner, AI fallback, TTS cooking improvements 2026-04-28 12:53:42 +00:00
dadaloop82 105c3298f3 chore: bump version to 1.5.0 2026-04-28 12:53:24 +00:00
dadaloop82 c3b19a6c48 feat: expired banner for opened products, AI model fallback, TTS cooking improvements
- Banner: detect expired opened-products via effective shelf-life (opened_at +
  estimateOpenedExpiryDays), not just raw expiry_date — fixes Fagioli/Panna case
- Banner: expired items show safety tip inline; danger-level items (fridge dairy,
  meat, fish) get red banner + 'L'ho buttato' as primary button, 'Usa comunque'
  demoted to grey; safety-ok/warning items keep original button order
- Banner: anomaly dismiss button now shows current inventory qty ('La quantità è
  giusta (2 pz)') so the action is unambiguous
- AI: add callGeminiWithFallback() helper — tries gemini-2.5-flash first (separate
  quota), falls back to gemini-2.0-flash; applied to all endpoints (expiry, chat,
  identify, recipe non-streaming, shopping name classifier)
- AI: show friendly 'Quota AI esaurita' message instead of raw Gemini error string
- Cooking TTS: fix auto-speak broken since 'auto-speak removed' comment — each step
  is now read automatically on navigate and on first step when entering cooking mode
- Cooking TTS: remove incorrect s.tts_enabled gate — _cookingTTS toggle is the only
  gate; browser Web Speech API used by default without requiring Settings config
- Cooking TTS: timer fires '10 secondi rimanenti' warning at T-10s
- Cooking TTS: announce recipe completion ('Buon appetito!') on last step confirm
- i18n: add timer_warning_tts, recipe_done_tts, error.ai_quota keys (IT/EN/DE)
- CSS: add banner-expired-danger, banner-safety-* styles for unsafe expired items
2026-04-28 12:46:00 +00:00
dadaloop82 8a16307b39 i18n: translate all hardcoded Italian strings in app.js
Added 49 new translation keys to all 3 language files (IT/EN/DE)
and wired every hardcoded Italian label/toast/hint in app.js to use
the t() translation function.

Sections covered:
- scale: density_hint, ml_hint, weight_detected, weight_too_low,
         stable, auto_confirm
- dashboard: banner review titles/details, prediction rate/days/
             direction texts, finished-zero/expected/check,
             anomaly phantom/ghost titles and details
- action: have_title, add_more_sub, use_qty_sub, throw_btn/sub, edit_sub
- add: purchase_type_label, new_btn, existing_btn,
       remaining_label/hint/full/half
- use: throw_title, throw_all, throw_qty_label/hint, throw_partial_btn
- shopping: bring_badge, add_urgent_toast, migration_done,
            added_to_bring, added_to_bring_skip, all_on_bring,
            removed_sufficient (was a complex plural, now uses key)
- toast: product_updated, thrown_away, thrown_away_partial
- confirm: kiosk_exit
- WEEK_DAYS array now uses t('days.*') keys
2026-04-28 06:36:30 +00:00
dadaloop82 1606cb3a90 docs: add v1.4.0 CHANGELOG and README updates for all features since 1.3.0 2026-04-28 06:20:50 +00:00
dadaloop82 608afb086d fix: bringMigrateNamesInternal — use PUT/remove and German catalog keys
Two bugs in the migration function:
1. DELETE endpoint does not exist in Bring! API — must use PUT with
   'remove' param (same as the remove-from-list flow elsewhere in the code)
2. Items were added using the Italian shopping_name as the 'purchase' field
   instead of the German catalog key via italianToBring(shoppingName).
   This created Italian/German duplicates (e.g. both 'Affettato' and
   'Aufschnitt' in the list at the same time).

Also add a pre-add duplicate check so existing catalog-key items are not
double-added when the old specific item is removed.

Manual cleanup run: removed 25 stale/duplicate items, added 8 correct
German-key items, ran migration (1 more migrated). List is now clean.
2026-04-27 18:14:27 +00:00
dadaloop82 d1478245da fix: add 24 missing shopping_name aliases to Bring! catalog (100% coverage)
All shopping_name values now resolve to a German Bring! catalog key:
  aroma / ingredienti spezie -> Zutaten & Gewürze
  bevande / liquore          -> Getränke & Tabak
  camomilla                  -> Tee
  cioccolata calda           -> Kakao
  cipolla                    -> Zwiebeln
  cracker / taralli / snack  -> Snacks & Süsswaren
  farina integrale           -> Mehl
  fette biscottate           -> Toast
  filetto                    -> Fleisch
  muesli                     -> Corn Flakes
  panna da cucina            -> Rahm
  passata / polpa pomodoro   -> Pelati
  piatti pronti/purè/sfornat -> Fertig- & Tiefkühlprodukte
  salsa                      -> Zutaten & Gewürze
  succo                      -> Fruchtsaft
  vino                       -> Rotwein
  zucchero di canna          -> Zucker
2026-04-27 17:37:01 +00:00
dadaloop82 cb75558581 fix: auto-migrate Bring! names to generic on every list load (throttled 10min)
- bringGetList() now runs bringMigrateNamesInternal() silently after
  returning the response, max once per 10 minutes (bring_migrate_ts.json flag)
- Refactored migration into bringMigrateNamesInternal() reusable function
- Removed manual migrate button from UI (not needed, it's automatic now)
- One-shot migration already executed via curl: 16 items updated in Bring!
2026-04-27 17:33:49 +00:00
dadaloop82 8258591e44 feat: migrate existing Bring! items to generic shopping names
- New API action bring_migrate_names: reads current Bring! list, matches
  items against products DB, replaces specific names with shopping_name
  (e.g. 'Mortadella IGP' → 'Affettato' with spec 'Mortadella IGP · Brand')
- New button in Bring! settings: 'Generalizza nomi lista Bring!'
  with live status feedback (migrated / skipped / errors count)
- Auto-refreshes shopping list view after migration
2026-04-27 17:29:55 +00:00
dadaloop82 28a8c938bd fix: prevent scale double-deduction (duplicate inventory_use)
Root cause: after scale auto-confirm fires submitUse(), the old code called
_cancelScaleAutoConfirm(false) which reset _scaleLastConfirmedGrams to null.
This allowed the scale (still showing the same reading) to start a new 10-second
stability+confirm cycle and trigger a second identical deduction.

JS fix:
- submitUse() now calls _cancelScaleTimersOnly() instead of
  _cancelScaleAutoConfirm(false), preserving _scaleLastConfirmedGrams so the
  same weight is rejected until the product is removed from the plate.
- _scaleStabilityVal reset to null so a genuinely new weight starts fresh.
- Duplicate API response (result.duplicate) silently ignored in the UI.

PHP fix (server-side safety net):
- useFromInventory() rejects a second 'out' transaction for the same product
  within 12 seconds with { success: false, duplicate: true }.
  This catches any client-side edge cases regardless of scale timing.
2026-04-27 17:01:11 +00:00
dadaloop82 d269f919b9 ci: trigger kiosk rebuild — include native TTS bridge (95389eb) 2026-04-27 14:49:53 +00:00
dadaloop82 679b3f16a8 ci: force kiosk APK rebuild with TTS bridge fix 2026-04-27 14:49:30 +00:00
dadaloop82 97f6681e24 ci: trigger kiosk APK build on develop branch too 2026-04-27 14:49:19 +00:00
dadaloop82 a5a6e80b31 fix: use product_shopping_name in all Bring! add paths from low-stock flow
- inventory_use API now returns product_shopping_name in response
- showLowStockBringPrompt: uses generic shopping name (e.g. Affettato) as
  Bring! item name, specific product name + brand as specification field
- addLowStockToBring: reads from window._lowStockName instead of arg
- Auto-add on depletion JS fallback: same generic-name pattern
- Deduplication check now tries both shoppingName and raw name
2026-04-27 13:45:10 +00:00
dadaloop82 fd5ff00d82 fix: comprehensive shopping name audit + README update
Shopping name system hardening (api/index.php):
- phraseMap: added sugar subtypes (zucchero a velo), passato di verdure/patate
  → Verdure, aroma phrases → Ingredienti Spezie, farina 00 → Farina,
  explicit brodo subtypes; added ordering comment on 'farina integrale'
- keywordMap: added 'lievito' → Lievito, 'aroma' → Ingredienti Spezie
  single-token fallbacks

DB migration (sqlite3 direct):
- farina integrale / Farina integrale di grano tenero → Farina integrale (3 rows)
- Prodotto sconosciuto (Belbake, it:farina-integrale) → Farina integrale
- Zucchero di canna → Zucchero di canna
- Passato di patate e carote → Verdure (it's a blended veg purée)
- Aroma mandorla per dolci → Ingredienti Spezie (consistent with other aromas)
Total: 11 products re-classified

README.md:
- Shopping List: added generic shopping name feature + auto-add on depletion
- Cooking Mode: updated TTS description (browser / native Android / REST)
- Kiosk: added native TTS bridge bullet
- Roadmap: checked off 3 new completed items
2026-04-27 12:17:11 +00:00
dadaloop82 1a73ed91dd fix: compound shopping names + auto-Bring on depletion + panna da cucina
1. shopping_name compound-phrase map (computeShoppingName)
   Add phraseMap checked against the full product name BEFORE the single-token
   keyword loop.  Prevents 'pane grattugiato' → 'Pane', 'panna da cucina' → 'Panna', etc.
   Key new phrases:
   - pane/pan grattugiato → Pangrattato
   - panna da cucina / panna cucina / panna chef → Panna da cucina
   - fette biscottate → Fette biscottate
   - aceto balsamico / glassa balsamico → Aceto balsamico
   - latte condensato/evaporato/vegetale/di soia/mandorla/avena/riso/cocco → specific
   - prosciutto cotto → Prosciutto cotto
   - farina di riso/mais/integrale → specific
   - pasta fresca, zucchero di canna, acqua minerale/frizzante/gassata, brodo, …
   Also added single-token safety-net entries: 'grattugiato'/'grattato'/'pangrattato'
   → 'Pangrattato', 'biscottate' → 'Fette biscottate'.

2. DB migration (sqlite3 UPDATE)
   Re-classified 10 products that had wrong shopping_name:
   Pane grattugiato → Pangrattato
   Panna da cucina (×4) → Panna da cucina
   Fette biscottate (×2) → Fette biscottate
   Aceto balsamico (×3) → Aceto balsamico
   Cleared 2 stale Gemini cache entries.

3. showLowStockBringPrompt (app.js)
   When totalRemaining <= 0 (product fully depleted), skip the modal entirely.
   The backend already auto-adds to Bring! on depletion; the JS only asks as a
   fallback if that failed (fire-and-forget async, never blocks the UI).
   The afterCallback (e.g. move-remainder modal, navigate to dashboard) is called
   immediately without user interaction.
2026-04-27 12:04:48 +00:00
dadaloop82 95389ebe87 fix: native Android TTS bridge in kiosk — bypass Web Speech API voice issues
On Android WebView, window.speechSynthesis.getVoices() often returns empty
because the Web Speech API cannot enumerate the device's TTS voices.
This caused the kiosk to show 'nessuna voce offline è supportata'.

Changes:
- KioskActivity.kt: initialise Android TextToSpeech engine on startup;
  expose speak(text, rate, pitch), stopSpeech() and isTtsReady() via
  the existing _kioskBridge JavascriptInterface; release TTS in onDestroy.
- app.js (_speakBrowser): when _kioskBridge.speak is available, delegate
  to it instead of using speechSynthesis — works even without offline voice
  packs installed.
- app.js (_initBrowserTtsVoices): show 'Voce nativa Android (kiosk)'
  in the voice dropdown when running inside the kiosk WebView.
- app.js (testTTS): use the bridge path when testing TTS inside the kiosk.
2026-04-27 11:52:30 +00:00
dadaloop82 8b5985dc80 feat: improve computeShoppingName — expanded keyword map + Gemini AI fallback
- Extended keyword map: 100+ entries covering bread (bauletto->Pane),
  cheese (bel->Formaggio, casatella->Formaggio), wine (vesoletto/trebbiano->Vino),
  foreign brand names (kaffee->Caffe, risofrolle->Cracker, zuppalatte->Biscotti,
  inchusa->Birra, apfelsaft->Succo, kartoffelpüree->Purè, ciobar->Cioccolata calda,
  ovomaltine->Bevande), desserts (gelato->Gelato), herbs (camomilla->Camomilla),
  liquors (sambuca->Liquore), sugar variants (zuccheri->Zucchero), foreign words
  (jus/zumo/arome->Italian equivalents)
- Add _geminiClassifyProduct(): calls Gemini 2.0 Flash for ambiguous product names,
  with persistent cache in data/shopping_name_cache.json (never re-queries same product)
- computeShoppingName() now calls Gemini when keyword map and Bring! catalog both fail
  and the product name is multi-token or has a brand/category hint
- saveProduct() auto-computes shopping_name on every create/update (already in prev commit)
- DB migration: all 210 products re-classified with new rules
- shopping list: 38->33 groups (Formaggio +4v, Affettato +5v, Biscotti +1v, Pane +1v...)
- Final SQL fixes for edge cases: Gelato, Camomilla, brand name single tokens
2026-04-27 11:40:14 +00:00
dadaloop82 430f9e7854 feat: generic shopping names — group products by shopping_name
- Add shopping_name column to products table
- Add computeShoppingName() PHP auto-assign function:
  * Curated keyword map: all salumi/cold cuts → 'Affettato'
  * Bring! catalog back-translation: 'Latte di Montagna' → 'Latte'
  * Fallback: first significant token capitalized
- Migrate all 210 existing products with auto-computed shopping_name
- saveProduct() auto-computes shopping_name on every create/update
- smartShopping() groups items by shopping_name: most urgent item is
  representative, others listed as variants (e.g. 'Affettato' shows
  Mortadella, Speck, Nduja, Salame, Prosciutto, Schinkenspeck as one row)
- _productOnBring() also checks shopping_name for Bring! match detection
- addToInventory auto-remove: uses shopping_name-based Bring! key
- useFromInventory auto-add: sends shopping_name to Bring! (not raw name),
  specific product name goes into specification field
- Frontend renderSmartItem: shows shopping_name as title, specific
  product name(s) in italic subtitle line below
- _syncOnBringFlags: matches on both name and shopping_name
2026-04-27 08:16:44 +00:00
dadaloop82 61e94db0d3 style: barcode suffix in banner title smaller + monospace 2026-04-27 05:54:28 +00:00
dadaloop82 76c4344720 feat: show last 3 barcode digits in finished-product banner title 2026-04-27 05:50:22 +00:00
dadaloop82 61e7d7d4bf fix: finished banner only fires when transaction balance is suspicious
getFinishedItems now:
- Computes total_in - total_out for every qty=0 row
- If balance <= unit threshold (e.g. <20g, <0.1 conf): product was
  legitimately used up → silently DELETE, no banner shown
- Only if balance > threshold (unexpected zero): return to frontend
  so banner asks user to verify
Banner detail now shows the expected residual qty so user understands
why the alert fired.
2026-04-27 05:47:11 +00:00
dadaloop82 36f6fcd232 fix: reduce finished-banner to only products with unexpected zero balance
Of the 75 qty=0 rows restored in previous commit, delete the 66 where
total_in - total_out <= unit threshold (legitimately finished by user).
Keep only 9 products where transaction math says there should still be
stock but inventory hit 0 (likely system/scale error):
- Latte di Montagna (0.41 conf)
- Passata di pomodoro (692g)
- Carote (80g), mele (6pz), Uova biologiche (1pz)
- Cipolla dorata (496pz), Panna da cucina (0.6 conf)
- Gran bauletto integrale e noci (448 conf), il tuo muesli mountain (331g)

These 9 will appear in the banner asking user to verify.
2026-04-27 05:43:59 +00:00
dadaloop82 5df0be1661 feat: keep qty=0 instead of auto-delete, ask user to confirm via banner
- useFromInventory: replace DELETE with UPDATE qty=0 when stock hits 0
  (both normal path and use-all-locations path)
- listInventory: add WHERE quantity > 0 so qty=0 rows are invisible in
  the regular inventory list
- New API actions: inventory_finished_items (query) and
  inventory_confirm_finished (delete after user confirms)
- Banner: new 'finished' type (priority 600, above anomalies)
  Shows: '{name} — è finito?' with two buttons
  'Sì, è finito' → permanently deletes the qty=0 row
  'No, ne ho ancora' → navigates to add-inventory form
- i18n: banner_finished_* and toast.product_finished_confirmed (it/en/de)
- DB migration: restored 75 auto-deleted products (last 30 days) as
  qty=0 inventory rows so they appear in the banner queue
2026-04-27 05:41:38 +00:00
dadaloop82 37299e60c9 fix: TTS voices — retry 10s, message on failure, refresh button
- Retry loop extended to 10s (50×200ms) for slow Android WebViews
- Show 'Nessuna voce disponibile' after timeout instead of infinite loader
- Show 'Voce non supportata dal browser' if speechSynthesis missing
- Reset to loading state on each settings open (fixes stale empty select)
- Add refresh button ↺ to force-reload voices manually
2026-04-26 17:28:05 +00:00
dadaloop82 f57ad4b330 fix: TTS voice selector stuck on 'Caricamento voci' in Chrome
Chrome loads voices async — getVoices() returns [] on first call
and onvoiceschanged may fire before the handler is assigned (race).
Add fallback retry loop: poll every 200ms for up to 4s.
2026-04-26 17:23:39 +00:00
dadaloop82 fe0221e6d4 fix: banner buttons — no icons, qty in confirm, full i18n 2026-04-26 17:17:05 +00:00
dadaloop82 4a780f2743 feat: smarter alert banners — expired only, explanatory messages
- Scadenze: rimuove 'in scadenza' dal banner (solo prodotti già scaduti)
- Consumo anomalo: spiega la media giornaliera, giorni dall'ultimo rifornimento
  e direzione della discrepanza (più/meno del previsto) con contesto
- Quantità sospetta: messaggio specifico per caso (bassa/alta/conf insolita)
- Anomalia inventario: linguaggio naturale invece di jargon tecnico
  phantom='hai più scorte del previsto', missing='hai meno scorte del previsto'
- API prediction: aggiunge days_since_restock, direction, tx_count
- confirmBannerPrediction: toast con info su ricalcolo previsioni
2026-04-26 16:57:09 +00:00
dadaloop82 546d4afd59 feat: SSE streaming recipe generation with live agent feedback
- Add generateRecipeStream() endpoint with real-time SSE status events
- Frontend generateRecipe() uses ReadableStream for live step updates
- Fix gemini-2.5-flash thinking model: disable thinkingBudget, raise maxOutputTokens to 4096
- Passo 2 is pure PHP heuristic (zero extra AI calls)
- Retry logic with live countdown on 429, fallback chain: 2.5-flash → 2.0-flash
- Pass all ingredients when meal plan is active (no limits)
- Add recipe-loading-msg element with CSS transition
2026-04-23 15:16:50 +00:00
dadaloop82 db033844d4 Gemini: centralizza chiamate API in callGemini() con backoff intelligente
- Aggiunto helper callGemini($url, $payload, $timeout):
  * Fino a 4 tentativi su 429 / 503
  * Legge Retry-After header dalla risposta HTTP di Google
  * Legge retryDelay dal corpo JSON di errore (es. '10s', '30s')
  * Backoff default: 2s, 4s, 6s (sovrascitto da Google se specificato)
- geminiReadExpiry(), geminiChat(), geminiIdentifyProduct(): rimosso curl
  diretto senza retry, ora usano callGemini()
- generateRecipe(): rimosso vecchio loop manuale (3 tentativi, 2s/4s fissi),
  ora usa callGemini() che rispetta i delay suggeriti da Google
- In caso di 429 finale restituisce il messaggio di errore da Google (non generico)
2026-04-22 11:38:47 +00:00
dadaloop82 f4a62ef496 feat: anomaly detection banner - notifica incongruenze inventario/transazioni
- New API endpoint 'inventory_anomalies': detects items where stored qty
  differs from tx history by >20% AND >50 units (phantom qty or missing qty)
- New API endpoint 'dismiss_anomaly': persists dismissal in anomaly_dismissed.json
- Banner system: new 'anomaly' type shown in dashboard alert banner
  with 'Correggi' (opens edit) and 'Ok, ignora' (dismisses) buttons
- CSS: banner-anomaly style (orange gradient)
- Fix: lo zucchero azzerato (175g fantasma rimossi), aggiunto a Bring!
2026-04-21 12:34:54 +00:00
dadaloop82 234cae14bc perf: remove Gemini from bringSuggest and product selection - pure offline logic
- bringSuggestItems: now uses smart_shopping cache (already computed offline)
  instead of calling Gemini with full inventory prompt
- aiSelectBestProduct: replaced Gemini call with token-scoring algorithm;
  scores by token overlap, first-anchor bonus, spec-variant matching,
  category mismatch penalty — 0ms vs ~1s per product search
- Only truly necessary AI calls remain: photo expiry, photo identify,
  chat assistant, recipe generation
2026-04-21 12:12:04 +00:00
dadaloop82 03142e2f7f fix: retry Gemini 429 with backoff, add recipe rate limit bucket (5/min) 2026-04-21 12:03:16 +00:00
dadaloop82 ce8133ad3f Merge branch 'develop' 2026-04-21 05:33:54 +00:00
dadaloop82 56c269d616 feat: tutte le operazioni Bring! ora completamente autonome in background
_backgroundBringSync() riscritto completamente:
- Aggiorna SEMPRE smartShoppingItems + shoppingItems con dati freschi
- Aggiunge item high/critical mancanti su Bring
- Aggiorna spec urgenza su item già presenti (anche downgrade)
- Rimuove automaticamente item auto-aggiunti quando non più urgenti
- Se la pagina shopping è aperta, re-renderizza i dati freschi
- Gira ogni 5 min via setInterval, non dipende da nessuna navigazione
- Aggiorna il badge urgenza dashboard in background
2026-04-21 05:33:52 +00:00
dadaloop82 3d8dc66ec1 Merge branch 'develop' 2026-04-21 05:32:15 +00:00
dadaloop82 5bbedc8a3b fix: aggiorna urgenza lista spesa live e più frequentemente
- Cache smart_shopping: 10min → 3min (urgenza fresca)
- backgroundBringSync: ogni 10min → ogni 5min + setInterval continuo
- backgroundBringSync: aggiunge anche 'high' (non solo 'critical') a Bring
- autoSyncUrgencySpecs: aggiorna spec anche se il livello di urgenza sale/scende
- Risultato: Latte/prodotti urgenti compaiono su Bring in max ~5min
2026-04-21 05:32:06 +00:00
dadaloop82 ae7d6772f6 Merge branch 'develop' 2026-04-20 17:40:21 +00:00
dadaloop82 cd4fd55006 fix: sposta bottone settings kiosk in basso-sinistra (non copre più camera/Gemini) 2026-04-20 17:40:19 +00:00
dadaloop82 03a63d34fc Merge branch 'develop' 2026-04-20 17:37:05 +00:00
dadaloop82 4e4c1867bf fix: abilita mod_headers nel Dockerfile (richiesto da .htaccess no-cache) 2026-04-20 17:37:03 +00:00
dadaloop82 ccc1b0cdcc Merge branch 'develop' 2026-04-20 17:34:25 +00:00
dadaloop82 517a615d11 fix: forza no-cache per JS/CSS + WebView LOAD_NO_CACHE
- .htaccess: aggiunge Cache-Control no-cache/no-store per .js/.css
- Kiosk Kotlin: aggiunge cacheMode=LOAD_NO_CACHE al WebView
- Il pulsante refresh del kiosk ora carica sempre l'ultima versione
2026-04-20 17:34:23 +00:00
dadaloop82 43624fafe1 Merge branch 'develop' 2026-04-20 17:29:58 +00:00
dadaloop82 5e01c0656c fix: bump cache-buster JS/CSS (v=20260420a) 2026-04-20 17:29:56 +00:00
dadaloop82 9240e20360 Merge branch 'develop' 2026-04-20 17:27:56 +00:00
dadaloop82 abd8ab1829 fix: sposta pulsanti kiosk dentro l'header, prima del titolo
I pulsanti ora sono inseriti nel flusso del header-content come primo figlio, non più come overlay fixed che copriva il titolo.
2026-04-20 17:27:54 +00:00
dadaloop82 d814601b30 Merge branch 'develop' 2026-04-20 17:18:35 +00:00
dadaloop82 076593c564 fix: sposta pulsanti kiosk (X/refresh) a sinistra, prima del titolo 2026-04-20 17:18:32 +00:00
dadaloop82 84fec6406a Merge branch 'develop' 2026-04-20 14:49:05 +00:00
dadaloop82 63b721cf09 fix: ripristina pulsanti kiosk (X/refresh) e riduce attesa stabilità bilancia a 5s
- Aggiunge iniezione overlay kiosk lato web (X chiudi + ↻ refresh) quando _kioskBridge è disponibile, più affidabile dell'iniezione Android-side
- Riduce timer stabilità bilancia da 10s a 5s
2026-04-20 14:48:57 +00:00
dadaloop82 e574e4d58d Merge branch 'develop' 2026-04-20 14:43:12 +00:00
dadaloop82 4db8882dbd feat: ottimizza prompt Gemini ricette (riduzione ~60% token) e migliora stabilità bilancia
Prompt ricette:
- Lista ingredienti compatta: skip staples, no brand, flag brevi (🔴3gg)
- Cap gruppo 4 a 40 items, gruppo 6 a 20
- Regole condensate da 10 verbose a 6 concise
- Testi condizionali (varietà, regen, opzioni) abbreviati
- Aggiunto detail errore API Gemini nel toast

Bilancia:
- Ignora oscillazioni sub-grammo (jitter 0.5g)
- Confronto integer-gram prima di dichiarare instabile
2026-04-20 14:43:05 +00:00
dadaloop82 745e042375 Merge develop: auto-sync Bring for items depleting within 7 days 2026-04-19 09:10:44 +00:00
dadaloop82 cedd97fd73 fix(bring): keep weekly depletion items auto-synced despite local purchased blocklist
- autoAddCriticalItems now bypasses _isBringPurchased for imminent items
  (days_left <= 7 and uses_per_month >= 3)
- prevents missing Bring additions for items predicted to finish within a week
  after temporary local blocklist entries
2026-04-19 09:10:39 +00:00
dadaloop82 2db86ca541 Merge develop: fix Bring mapping ambiguity on generic words 2026-04-19 09:07:50 +00:00
dadaloop82 c115f83879 fix(bring): avoid ambiguous fallback match on generic words like 'dolce'
- italianToBring(): pass-2 whole-word fallback now ignores generic qualifiers
  (dolce, light, classico, originale, etc.)
- when multiple single-word matches exist, choose the longest/specific token
  instead of first catalog iteration hit
- prevents wrong mappings like 'Pancetta Dolce' being interpreted via generic
  adjective rather than the core product token
2026-04-19 09:07:44 +00:00
dadaloop82 ed8c6fbd07 Merge develop: auto-add to Bring! items running out <7 days 2026-04-19 06:52:19 +00:00
dadaloop82 d778817fd8 fix: auto-add to Bring! items running out within 7 days
- autoAddCriticalItems now adds:
  - critical: always (unchanged)
  - high: always (removed sub-conditions — PHP already gates high urgency strictly)
  - medium + days_left <= 7 + uses_per_month >= 3 (NEW: weekly depletion rule)
- Reduce auto-add guard from 10 min to 5 min for faster detection
- Covers: Latte di Montagna (high, 3gg), items running out mid-week (medium, 4-7gg)
2026-04-19 06:52:16 +00:00
dadaloop82 33163b9235 Merge develop: smarter proactive shopping list urgency 2026-04-19 06:06:36 +00:00
dadaloop82 1021f04735 fix: smarter proactive shopping list urgency
- PHP: predictive urgency block now scales by imminence:
  round(days_left) <= 3 → high, <= 7 → medium, <= 14 → low
  (was flat 'low' for any days_left <= 14)
- PHP: also upgrades existing 'low' urgency to 'high' when
  imminent depletion detected (round(days_left) <= 3, isFrequent)
- JS: autoAddCriticalItems now also adds:
  - high urgency items with pct_left < 20% (nearly empty)
  - high urgency items with days_left <= 3 (imminent)
  - any item with days_left <= 2 and uses_per_month >= 5

Result: Latte di Montagna (27.8x/mo, 3 days left) now appears
on shopping list before running out, as do Lenticchie/Riso
Basmati at 1% stock and Sandwich at 1 day left.
2026-04-19 06:06:18 +00:00
dadaloop82 3901113e76 fix: Storico nav icon, recipe title in transaction notes and log display
- index.html: replace broken byte with 📋 emoji in Storico nav button
- submitRecipeUse: pass 'Ricetta: <title>' as notes to inventory_use API
  so every ingredient use is linked to the recipe in the DB
- loadLog: render recipe note as small italic line 🍳 below log-detail row
- style.css: add .log-recipe-note style (0.75rem, muted, italic)
2026-04-18 19:12:07 +00:00
dadaloop82 2edd5a6ebd fix(kiosk): skip debug keystore config when file does not exist (CI fix) 2026-04-18 18:56:27 +00:00
dadaloop82 37cd8caf4f Merge develop into main: kiosk overlay, preferred location, scale reconnect, Bring! fixes, smart cache 2026-04-18 18:51:55 +00:00
dadaloop82 07bdfe6b87 fix: kiosk overlay, preferred use-location, scale reconnect, Bring! translation, smart cache invalidation
- Kiosk: replace header-inject overlay with position:fixed div appended to <html>
  so buttons appear regardless of SPA init timing
- Kiosk: bump versionCode 3→4, versionName 1.2.0→1.3.0
- Kiosk: add explicit signingConfigs block (debug keystore) to avoid signature
  mismatch on updates; update banner now shows uninstall instruction + 12s timeout
- Web: v1.4.0 → v1.5.0
- Preferred use-location: remember last N location choices per product; after 3+
  consistent picks auto-select and collapse location picker (with 'cambia' link)
- Scale: call updateScaleReadButtons() on every status change so live-box and
  read button appear instantly on reconnect without manual refresh
- Smart shopping cache: invalidate JSON cache file on every inventory_add and
  inventory_use so next shopping-page load always sees current stock
- isLowStock: conf threshold changed <= 1 → < 1 (1 full pack is not low stock)
- italianToBring: replace substring matching with whole-word matching (min 4 chars)
  to prevent 'gin' matching 'original', 'rum' matching 'crumble', etc.
  Philadelphia original was silently mapped to Gin and skipped as duplicate
- Storico: add undo support (transaction_undo endpoint, undone column, JS undo btn)
- LOG → Storico rename in UI, nav, translations
- Bring! sync: urgency-aware purchased blocklist TTL (critical 30m, high 90m, others 4h)
- forceSyncBring() button to clear all guards and re-sync from scratch
- Scale live-box: position:fixed CSS class, 1.6rem/800 value, direct ml display
- Recipe use modal: scale live-box with 10s stability + 5s auto-confirm countdown
- Recipe use modal: show recipe quantity as highlighted row in Usa popup
2026-04-18 18:50:15 +00:00
dadaloop82 7dba155183 Merge develop: v1.3.0 — banner notifications, quick-access, swipe navigation, bug fixes 2026-04-18 05:37:16 +00:00
dadaloop82 9e2a24def4 feat: v1.3.0 — banner notifications, quick-access, swipe navigation, bug fixes
Added:
- Expired/expiring product banner alerts with use, throw, edit, dismiss actions
- Priority-sorted notifications (expired > expiring > suspicious qty > predictions)
- Touch swipe navigation for banner with dot indicators and arrow buttons
- Quick-access buttons on inventory (4 recent + 8 popular products)
- Auto-refresh banner every 5 min on dashboard
- Edit expiry dates directly from expired/expiring notifications

Fixed:
- Ignore negative BLE scale readings
- Banner re-appearing after edit (confirmation now persisted)
- False consumption predictions when inventory was manually edited
- Kiosk overlay no longer blocks web app header
2026-04-18 05:37:03 +00:00
dadaloop82 52cfbba663 docs: update all READMEs with new features
- README.md: add anomaly banner, AI local matching, scale 10g threshold,
  ml conversion hint, auto-fill improvements, Android kiosk section,
  updated roadmap with completed items, architecture tree with kiosk
- evershelf-kiosk/README.md: complete rewrite — now reflects pure WebView
  wrapper architecture (no BLE), setup wizard, kiosk lock, exit/refresh
  buttons, permissions, gateway auto-launch, update notifications
- evershelf-scale-gateway/README.md: update architecture diagram to show
  SSE relay path, add kiosk integration note
2026-04-17 06:14:33 +00:00
dadaloop82 32e2833b27 feat: AI scan shows existing products, scale ml hint, 10g threshold
1. AI photo scan: searches local DB for matching products and shows
   'Già in dispensa' section before OFF matches. User can tap an
   existing product directly. 'Non è nessuno di questi' button for
   new products.

2. Scale live box: when product unit is ml, shows hint
   'Peso in grammi → verrà convertito in ml' so user knows the
   gram reading will be converted.

3. Scale auto-fill: ignores stable weight if it differs less than
   10g from the last confirmed reading. Prevents re-triggering the
   same weight when switching between products on the scale.
   _scaleLastConfirmedGrams tracks the last auto-confirmed weight
   and resets on page navigation.
2026-04-17 05:42:48 +00:00
dadaloop82 ccd59269d4 feat(kiosk): move exit button left of title, add hard-refresh button
- Exit ✕ and Refresh ↻ buttons now appear left of the title
- Refresh clears WebView cache and reloads (picks up web app updates)
- Uses native bridge hardReload() for true cache-busting reload
- Banner alerts reload automatically when dashboard is shown
2026-04-17 05:24:22 +00:00
dadaloop82 d37b43003c feat: show brand in anomaly banner title
Example: 'Anomalia: Latte (Granarolo)'
2026-04-16 19:22:27 +00:00
dadaloop82 9083e25f37 feat(kiosk): add camera, microphone, storage permissions
- Manifest: CAMERA, RECORD_AUDIO, READ_EXTERNAL_STORAGE, READ_MEDIA_IMAGES
- Runtime: requests all permissions on startup (requestAllPermissions)
- WebView: onPermissionRequest checks runtime grants, requests if needed
- onRequestPermissionsResult grants pending WebView permission after user allows
- Camera and mic now work inside the kiosk WebView
2026-04-16 19:07:30 +00:00
dadaloop82 bc70f330f8 fix(kiosk): replace header overlay with exit button next to Gemini
- Removed invisible overlay that was blocking camera/Gemini buttons
- Added a visible ✕ button in the header-actions bar
- Tap shows confirm dialog 'Uscire dalla modalità kiosk?'
- All header buttons (camera, Gemini, scale) work normally again
2026-04-16 18:59:53 +00:00
dadaloop82 4250a37f0d fix: dismiss banner item after editing anomaly from banner
- Added _bannerEditPending flag set when edit/weigh triggered from banner
- submitEditInventory now calls dismissBannerItem() after save
- Next banner item shows automatically after correction
- Flag reset on modal close (cancel) to prevent stale state
2026-04-16 18:58:06 +00:00
dadaloop82 c45b8ddbb9 fix(kiosk): remove FLAG_ACTIVITY_LAUNCH_ADJACENT causing split-screen
Was triggering multi-window split mode instead of launching gateway
behind the kiosk. Now uses only FLAG_ACTIVITY_NEW_TASK.
2026-04-16 18:46:27 +00:00
dadaloop82 45040f250c fix: triple-tap exit, update banner auto-dismiss, .env overwrite bug
- Triple-tap exit zone now covers full header height (was 6px, untappable)
- Uses touchend event instead of click for reliable tablet interaction
- JS bridge registered once before loadUrl (not on every page load)
- Update banner auto-dismisses after 3 seconds
- CRITICAL: _finishSetup() no longer sends empty strings to save_settings
  → was overwriting .env values (Gemini key, Bring credentials) with blanks
  → now only sends non-empty values to the API
2026-04-16 17:36:48 +00:00
dadaloop82 e38a6cb7f6 feat(kiosk): true kiosk mode, gateway bg launch, update checks, wizard fix v1.2.0
- Screen pinning (startLockTask) blocks home/recent buttons
- Gateway launches in background, kiosk returns to front after 1.5s
- Injected thin green bar at top of WebView for triple-tap exit
- JavaScript bridge for kiosk exit from WebView context
- Update check via GitHub releases API (every 6h)
- Shows banner in WebView when kiosk/gateway updates available
- Setup wizard no longer re-appears after completion/skip (evershelf_setup_done flag)
- REORDER_TASKS permission for moveTaskToFront
- singleTask launch mode for proper kiosk behavior
- Version bumped to 1.2.0 (versionCode 3)
2026-04-16 17:25:47 +00:00
dadaloop82 5991e666ec fix(kiosk): connection test, gateway detection, splash, triple-tap exit
- Fix 404: test base URL directly instead of appending /api/
- Fix gateway not detected: add <queries> block for Android 11+ package visibility
- Add splash screen with icon + loading spinner (1.5s)
- Add triple-tap on wizard title to exit kiosk mode
- Accept 3xx redirects as valid responses
- New house/shelf vector icon for launcher
- Replace emoji icon with drawable on welcome screen
- Version 1.1.0
2026-04-16 17:04:25 +00:00
dadaloop82 9363bc147e refactor(kiosk): remove built-in scale, add SSL + gateway detection
- Remove BLE/scale code (BleScaleManager, ScaleProtocol, GatewayWebSocketServer, ScaleGatewayService)
- Kiosk is now a pure WebView wrapper — scale handled by standalone gateway app
- Fix SSL certificate error: accept self-signed certs for local servers (WebView + connection test)
- Add gateway APK detection: check if it.dadaloop.evershelf.scalegate is installed
- If gateway installed: show green status, auto-launch on finish
- If not installed: show download link to GitHub releases
- Remove BLE/foreground service permissions from manifest
- Remove java-websocket dependency
- Bump version to 1.1.0
2026-04-16 16:40:11 +00:00
dadaloop82 f8c8dfb990 fix(kiosk): match statusCallback signature (3 params) 2026-04-16 16:27:09 +00:00
dadaloop82 95b6258ad8 feat(kiosk): add setup wizard on first launch
- 3-step wizard: Welcome → Server URL → Scale Setup → Launch
- Connection test with live feedback
- BLE scale status during wizard
- Dark theme with modern UI (slate/purple palette)
- Settings page with URL edit, connection test, scale info, wizard reset
- Skip scale option for users without BLE scales
- Error page with retry button when server unreachable
- All UI in English
2026-04-16 16:23:13 +00:00
dadaloop82 d931b471f0 fix: add missing launcher icons for kiosk app
- Adaptive icon XMLs (API 26+) with vector foreground + green background
- PNG fallbacks for all density buckets (mdpi through xxxhdpi)
- Added ic_launcher_background color to colors.xml
2026-04-16 16:06:25 +00:00
dadaloop82 383ef1113d feat: kiosk APK download banner in settings page
- Green-themed banner between settings panels and Save button
- Direct download link to kiosk-latest release APK
- Auto-hidden when running inside Android WebView (kiosk mode)
- i18n: translations added for it/en/de
2026-04-16 16:00:40 +00:00
dadaloop82 1c792a4e4a ci: add GitHub Actions workflow for kiosk APK build 2026-04-16 14:47:35 +00:00
dadaloop82 3e25fcd5df feat: banner alerts, consumption predictions, scale improvements, kiosk app
- Banner notification system: suspicious quantities + consumption prediction alerts
- Consumption predictions API: tracks 90-day usage patterns, flags >30% deviations
- Scale stability timeout: 5s → 10s, auto-confirm remains 5s
- Scale integration in edit form: weigh button with inline live display
- Banner edit/weigh actions open edit form directly with scale activation
- Cooking mode: Italian aliases + stem-prefix matching for ingredients
- Recipe regeneration: tracks rejected ingredients for diversity
- Settings migration: localStorage → .env server-side storage
- Expiry priority: mandatory ≤3 days, recommended ≤7 days in recipes
- Scale bug fixes: clear stale weight, double-submit guard, cap deduction
- Android kiosk app (evershelf-kiosk): WebView + embedded BLE scale gateway
- Version bump to 1.4.0
2026-04-16 14:46:30 +00:00
dadaloop82 3ff91b3018 fix(scale): progress-bar restart loop + low-weight warning + gateway auto-reconnect cycle
- Fix progress-bar restarting continuously when weight is stable:
  add _cancelScaleTimersOnly() that stops timers/animations without
  _cancelScaleTimersOnly() so the same value resumes counting when
  stability returns instead of always restarting the 5-s wait.
  Add 'else if' branch in _scaleAutoFillUse / _scaleAutoFillRecipeUse
  to restart stability wait after brief instability for the same value.

- Show red blinking warning in scale-live-box when weight < 10 g:
  adds scale-low-weight CSS class with pulsing border/shadow animation,
  the label shows '< 10 g · inserisci manualmente' instead of the
  stability progress bar.  No auto-confirm fires below 10 g.

- Gateway Android app: scale auto-reconnect now retries indefinitely.
  isAutoReconnecting flag keeps the scan→wait→scan cycle running until
  the scale is found again; onScanStopped schedules a new scan after
  10 s whenever autoReconnect is active and scale is still offline.
2026-04-16 06:25:40 +00:00
dadaloop82 1c686fa842 feat(gateway): auto-reconnect to scale after disconnect (scale auto-off)
When the scale turns off by itself (auto-off after inactivity), onDisconnected()
now automatically restarts BLE scan after 5 s, with enableAutoConnect() set so
the saved scale is connected as soon as it starts advertising again.
The hint text shows '🔄 Reconnecting to saved scale in 5 s…' during the wait.
2026-04-15 21:17:34 +00:00
dadaloop82 951ef1d64f fix(scale): auto-fill broken for conf products (e.g. latte)
Two root causes:
1. _useNormalUnit was stale ('pz') for conf products because it's only
   updated in normal mode — fix: resolve effective unit from
   _useConfMode.packageUnit when in sub mode
2. Food scales in liquid mode send unit='ml' directly — was falling
   through to raw value, skipping density; fix: detect scaleAlreadyMl
   flag to use ml directly for ml target, or apply density for g target
Also: add scale pre-fill call after switchUseUnit('sub') in conf mode
2026-04-15 21:12:37 +00:00
dadaloop82 7144ec7386 feat(scale): g→ml density conversion for liquid products
- _scaleDensityForProduct(): returns g/ml density based on product name/category
  olio oliva 0.91, girasole 0.92, spirits 0.94, aceto/panna 1.01, latte 1.03,
  yogurt 1.05, succo 1.04, miele/sciroppo 1.40, default 1.00 (water)
- _scaleAutoFillUse(): normalises all scale units to grams first, then converts
  to target unit; when unit=ml applies density; never touches pz/conf
2026-04-15 21:08:41 +00:00
dadaloop82 d26229800c feat(scale): auto-fill use-quantity live from scale reading
- _scaleAutoFillUse(): converts and fills use-quantity on stable readings
- _scaleLatestWeight pre-fills when use page opens (after unit is known)
- oninput on use-quantity pauses auto-fill when user types manually
- showUseForm() resets paused flag so next opening resumes auto-fill
- Small 'live' hint text shown while auto-filling, hidden when overridden
2026-04-15 21:06:12 +00:00
dadaloop82 6f5bc15734 fix: use relative API paths (leading / broke /dispensa/ subdir routing) 2026-04-15 21:00:34 +00:00
dadaloop82 55c5b34381 feat(scale): auto-discover gateway on local network
- api/scale_discover.php: async TCP scan of whole /24 subnet on port 8765,
  confirms with WebSocket handshake, returns found ws:// URLs in ~1.5s
- index.html: '🔍 Auto' button next to gateway URL field
- app.js: discoverScaleGateway() — calls relay, fills URL field and
  auto-saves settings + reconnects on success
2026-04-15 20:56:54 +00:00
dadaloop82 099a6cc4e8 fix: HTTPS/WebSocket mixed-content — add PHP SSE relay for scale gateway
The browser (HTTPS) cannot connect to ws:// directly (mixed-content block).
Solution: PHP SSE relay bridges the gap server-side.

- api/scale_relay.php: GET ?url=ws://ip:port
  PHP opens WS connection to Android gateway, streams JSON frames as
  Server-Sent Events to the browser over existing HTTPS connection.
  Includes WS handshake, masked client frames, frame decoder, keep-alive.

- api/scale_ping.php: GET ?url=ws://ip:port
  One-shot connectivity test, returns {"ok":true} or {"ok":false,"error"}.

- app.js: WebSocket -> EventSource (SSE)
  _scaleWs -> _scaleEs, connects to /api/scale_relay.php
  testScaleConnection() -> fetch /api/scale_ping.php (no more direct ws://)
  readScaleWeight(): removed get_weight send (scale streams continuously)
2026-04-15 20:31:54 +00:00
dadaloop82 a146ba124a feat(gateway): fix QN-KS 0.1g resolution, unit passthrough, English UI
- ScaleProtocol: WeightReading now holds Float value + String unit
- parseQNFood: divide raw by 10 (0.1-unit resolution) so 170 raw -> 17.0g
- parseQNFood: detect unit from byte[4] (0x01=g, 0x02=oz, 0x03-04=ml)
- GatewayWebSocketServer: publishWeight(value: Float, unit: String, ...)
  WebSocket now sends {"value":17.0,"unit":"g"} with correct precision
- BleScaleManager: reading.grams -> reading.value (Float check > 0f)
- All Italian UI strings translated to English in all 4 Kotlin files + XML
2026-04-15 20:02:51 +00:00
dadaloop82 7be02c7174 chore(gateway): add .gitignore, remove build artifacts from git 2026-04-15 19:46:07 +00:00
dadaloop82 0a35e9e8b4 fix(gateway): add QN-KS food scale parser (QN/Yolanda FFF1 protocol)
The QN-KS sends 18-byte frames on FFF1 with opcode 0x10:
  [0x10][0x12=len][...][flags][weight_hi][weight_lo][...][crc]

Weight is u16BE at bytes 9-10 in grams (1g resolution).
Stable flag is bit 3 of byte[8] (0xF8=stable, 0xF0=settling).
Checksum = sum(bytes[0..16]) mod 256.

The generic parser was reading byte[1]=0x12=18 as '18 grams' (the
packet length field), which is why it always showed 18g.

Added parseQNFood() with CRC validation, detected before generic fallback.
Also added AE00/AE02 UUIDs (secondary notifiable service on QN-KS).
2026-04-15 19:45:48 +00:00
dadaloop82 690d5ecd18 fix(gateway): remove trailing junk from ScaleProtocol.kt causing build failure
The file had 6 lines of old code appended after the closing brace,
starting a second 'package' declaration that broke compilation.
2026-04-15 16:33:42 +00:00
dadaloop82 b606e2b361 feat(gateway): show app version in header + copy/share debug log
- Version label (e.g. v2.0.0 (6)) displayed in the app header
- Copy and Share buttons appear when debug panel is open
- Copy puts full log in clipboard, Share opens Android share sheet
2026-04-15 16:07:22 +00:00
dadaloop82 d839a7e267 feat(gateway): food scale only - remove all body scale code v2.0.0
BREAKING CHANGE: WeightReading now uses grams (Int) instead of weightKg (Float).
All body composition fields removed (fat%, BMI, muscle, water, bone, kcal, impedance).
WebSocket always sends {unit:'g', value:<grams>}.

- ScaleProtocol: removed QN, 1byone, Hesley, Renpho, Body Composition parsers
- ScaleProtocol: generic parser tries grams, 0.1g, 0.5g, cg, kg*100, oz*10 (1-15000g)
- BleScaleManager: food scale keyword scoring, demotes body scale devices
- BleScaleManager: simplified service discovery (SIG Weight, FFE0, FFF0, Acaia)
- MainActivity: always displays grams, no body composition UI
- GatewayWebSocketServer: publishWeight(grams: Int), always sends unit='g'
- Version bumped to 2.0.0 (versionCode=6)
2026-04-15 16:00:57 +00:00
dadaloop82 d03a4853b5 feat(gateway): food/kitchen scale support (arboleaf CK10G) v1.6.0
- Generic parser now supports food scale weight ranges (1g-15kg)
  with candidates for gram, 0.1g, and 0.5g divisors
- WebSocket sends grams (unit: 'g') for weights under 15kg
- MainActivity displays grams for food-scale readings
- Enhanced debug: raw byte dump on decode failure with index/decimal/hex
- versionCode=5, versionName=1.6.0
2026-04-15 15:49:12 +00:00
dadaloop82 71c49e2c82 fix(gateway): clean ScaleProtocol.kt - remove Unicode box-drawing chars and fix duplication
- Remove Unicode ─ characters from section comment dividers that caused
  Kotlin compiler parse error (line 191:83 Closing bracket expected)
- Use plain ASCII dashes in section comments
- Remove inline field comments from data class
- File was duplicated (672 lines), truncated to correct 316 lines
2026-04-15 15:27:06 +00:00
dadaloop82 d30e9e0aaa gateway v1.5.0: protocol-aware parsers, debug fix, auto-reconnect
BREAKING FIX: 'always 1.8 kg' — the old brute-force parseGeneric was
matching noise bytes. Replaced with protocol-specific parsers:

Protocol support (from openScale research):
  - Bluetooth SIG 0x2A9D/0x2A9C (standard weight/body composition)
  - QN/Yolanda/FITINDEX (opcode 0x10 weight, 0x12 scale info)
  - 1byone/Eufy (0xCF header, LE weight at bytes 3-4)
  - Hesley/YunChen (20-byte body composition frame)
  - Renpho proprietary (0x2E header on 0x2A9D)
  - Safe generic fallback (stricter: min 4 bytes, min 2kg, unstable)

Body composition fields: fat%, muscle%, water%, bone, BMR/kcal,
impedance — all displayed when available.

Debug panel fix: capped at 150 lines, UI updates throttled to 200ms
(was: unbounded StringBuilder updated on every BLE notification = freeze).

Auto-reconnect: saves last connected device MAC to SharedPreferences,
auto-starts scan on app launch and connects when saved device found.

GATT service discovery: now explicitly subscribes to QN (FFE0/FFE1)
and custom FFF0 (FFF4 or FFF1) characteristics in addition to
standard Weight Scale and Body Composition services.

ScaleProtocol state: resetState() called on new connection to reset
QN weight divisor (100 or 10, learned from 0x12 info frame).
2026-04-15 15:11:22 +00:00
dadaloop82 695ea19d5c gateway: UX improvements + debug mode v1.4.0
- Show device names from ScanRecord (fixes MAC-only display)
- Show 'Senza nome' for unnamed devices instead of hiding them
- Show proximity (Vicino/Medio/Lontano) instead of raw dBm
- Sort scan results: scale-likely devices first (keyword + UUID scoring)
- Add debug panel (toggle with 🐛 Debug button):
  shows GATT service map, raw hex bytes, parse attempts
- Expand parseGeneric: all 2-byte windows × 3 resolutions × LE+BE
  (adds 0.1f and big-endian candidates – common in cheap consumer scales)
- Log GATT services/characteristics after connection
- Log raw hex bytes on every characteristic notification
2026-04-15 13:15:44 +00:00
dadaloop82 4d972b824e fix: vacuum state in move-after-use modal + show all recipe ingredients in cooking mode
- showMoveAfterUseModal: add '🫙 Torna sotto vuoto' checkbox (default=previous state)
  only shown if item was vacuum sealed; confirmMoveAfterUse passes vacuum_sealed to API
- showRecipeMoveModal: same vacuum checkbox with wasVacuum default passed from ingredient
- confirmRecipeMove: passes vacuum_sealed to inventory_update
- PHP API: add vacuum_sealed to recipe ingredient enrichment
- renderCookingStep: remove step-text word filter; show ALL unused from_pantry
  ingredients at every cooking step (AI uses pronouns like 'tagliarla' instead of
  repeating the ingredient name, causing them to be invisible)
2026-04-15 11:05:23 +00:00
dadaloop82 0830b1b168 fix: remove BLE scan UUID filter to support non-standard scales (LePulse, etc)
Many consumer scales like LePulse FI2016LB don't advertise standard
Weight Scale (0x181D) or Body Composition (0x181B) service UUIDs.
Remove the scan filter so all BLE devices are discovered.
The GATT fallback already handles proprietary services.
2026-04-14 17:18:41 +00:00
dadaloop82 7ff7f56e0b fix: fix invalid XML in launcher foreground vector drawable 2026-04-14 16:54:11 +00:00
dadaloop82 2740be3bdf fix: add missing launcher icon resources (mipmap + adaptive icon) 2026-04-14 16:49:50 +00:00
dadaloop82 d1c46a0bcb fix: add gradle.properties with android.useAndroidX=true for APK build 2026-04-14 16:45:23 +00:00
dadaloop82 df9de2d257 ci: fix broken YAML - multiline notes had zero indentation breaking the literal block 2026-04-14 16:42:35 +00:00
dadaloop82 fb134128fe ci: trigger workflow with direct commit on main 2026-04-14 16:39:39 +00:00
dadaloop82 329eed5082 Merge branch 'develop' 2026-04-14 16:38:09 +00:00
dadaloop82 0a11214d3d ci: remove paths filter - run on every push to main to ensure APK is always built 2026-04-14 16:38:07 +00:00
dadaloop82 390221ed4c Merge branch 'develop' 2026-04-14 16:33:38 +00:00
dadaloop82 38d8fa7afe ci: fix workflow - use gradle directly instead of ./gradlew (wrapper jar not in repo) 2026-04-14 16:33:35 +00:00
dadaloop82 18af2e9ef4 Merge branch 'develop' 2026-04-14 16:31:59 +00:00
dadaloop82 3fcded1d9b ci: also trigger workflow on changes to the workflow file itself 2026-04-14 16:31:56 +00:00
dadaloop82 b7dd197944 Merge branch 'develop' 2026-04-14 16:29:12 +00:00
dadaloop82 9523b68fea ci: add GitHub Actions workflow to build & release Scale Gateway APK
- Add .github/workflows/build-scale-gateway.yml
  Triggers on push to main (evershelf-scale-gateway/** path filter)
  Builds debug APK with Gradle/JDK 17, renames to evershelf-scale-gateway.apk
  Creates/updates 'latest' GitHub Release so the direct download URL resolves
- Bump web app version v1.2.0 -> v1.3.0 (index.html)
- Bump Android versionCode 1->2, versionName 1.0.0->1.3.0 (app/build.gradle.kts)
2026-04-14 16:29:00 +00:00
dadaloop82 0893742f05 feat: add smart scale BLE gateway integration
- Add evershelf-scale-gateway/ Android app (Kotlin):
  - BLE scanning and GATT connection to smart scales
  - Supports BT SIG Weight Scale (0x181D), Body Composition (0x181B), and generic heuristic parser
  - WebSocket server on port 8765 (local LAN)
  - Real-time weight broadcasting to EverShelf browser client
- Add scale status indicator in header (green/orange/grey dot)
- Add Settings tab for scale configuration (URL, enable toggle, test, APK download link)
- Add 'Read from scale' button in Add/Use forms when unit is g or ml
- Add scale WebSocket client logic in app.js with auto-reconnect
- Fix recipe suggestion: expiry-prioritized ingredients now only injected into
  AI prompt when user explicitly selects 'Priorità Scadenze' or 'Zero Sprechi'
- Update README with smart scale section and website link
- Update all translations (it, en, de) with scale strings
2026-04-14 15:59:40 +00:00
dadaloop82 7fa8395e9e docs: move screenshots section to end of README, translate to English 2026-04-13 11:02:56 +00:00
dadaloop82 f2b518dd4b docs: move screenshots section to end of README, translate to English 2026-04-13 11:02:49 +00:00
dadaloop82 2f21be8829 docs: merge screenshots and README update from develop 2026-04-13 11:00:45 +00:00
dadaloop82 7e9ae24f88 docs: add 9 app screenshots to README and assets
- Add 9 curated screenshots to assets/img/screenshots/ covering all main features
- Update README with 3x3 screenshot table with Italian descriptions
- Anchor /screenshots/ gitignore rule so assets/img/screenshots/ is tracked
- Screenshots cover: dashboard, inventory, barcode scanner, recipe detail,
  recipes list, cooking mode, AI chat, shopping list, smart predictions
2026-04-13 11:00:25 +00:00
dadaloop82 0c6a07ee95 fix: preserve version badge during i18n translatePage
Move data-i18n to inner <span> so translatePage() with textContent
does not overwrite the sibling .header-version span.
2026-04-13 10:23:26 +00:00
114 changed files with 26924 additions and 3273 deletions
-1
View File
@@ -7,7 +7,6 @@ data/cron.log
data/smart_shopping_cache.json
data/bring_token.json
data/bring_catalog.json
data/dupliclick_token.json
data/client_debug.log
data/*.crt
data/*.pem
+3
View File
@@ -20,3 +20,6 @@ TTS_AUTH_TYPE=bearer
TTS_CONTENT_TYPE=application/json
TTS_PAYLOAD_KEY=message
TTS_ENABLED=false
# GitHub Error Reporting: token is hardcoded in api/index.php (same for all clients).
# No .env entry needed — update GH_ISSUE_TOKEN constant in api/index.php to rotate.
+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"
+78
View File
@@ -0,0 +1,78 @@
name: Build & Release Kiosk APK
on:
push:
branches: [main]
paths:
- 'evershelf-kiosk/**'
workflow_dispatch:
permissions:
contents: write
jobs:
build:
name: Build Kiosk APK
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v3
with:
gradle-version: '8.6'
- name: Get version name
id: version
run: |
VERSION=$(grep 'versionName' evershelf-kiosk/app/build.gradle.kts | grep -oP '"\K[^"]+')
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
echo "Kiosk version: $VERSION"
- name: Build debug APK
run: gradle assembleDebug --no-daemon
working-directory: evershelf-kiosk
- name: Rename APK
run: |
mkdir -p artifacts
cp evershelf-kiosk/app/build/outputs/apk/debug/app-debug.apk artifacts/evershelf-kiosk.apk
# Publish with a semver-compatible tag so the in-app update check can
# compare versions numerically (tag "kiosk-1.7.0" → norm → "1.7.0").
# Also update the "kiosk-latest" tag so the hardcoded download URL still works.
- name: Create versioned release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="kiosk-${{ steps.version.outputs.name }}"
# Delete old release with same tag if it exists (e.g. re-run on same version)
gh release delete "$TAG" --yes 2>/dev/null || true
git push --delete origin "$TAG" 2>/dev/null || true
gh release create "$TAG" \
--title "EverShelf Kiosk v${{ steps.version.outputs.name }}" \
--notes "Kiosk mode app. Scarica e installa su Android 7.0+. L'aggiornamento OTA è automatico." \
--latest \
artifacts/evershelf-kiosk.apk
- name: Update kiosk-latest tag (for hardcoded download URL)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release delete kiosk-latest --yes 2>/dev/null || true
git push --delete origin kiosk-latest 2>/dev/null || true
sleep 3
gh release create kiosk-latest \
--title "EverShelf Kiosk Latest" \
--notes "Alias automatico → kiosk-${{ steps.version.outputs.name }}" \
--prerelease \
artifacts/evershelf-kiosk.apk
+64
View File
@@ -0,0 +1,64 @@
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:
workflow_dispatch:
inputs:
confirm:
description: "Type 'yes' to confirm you want to build the deprecated gateway APK"
required: true
permissions:
contents: write
jobs:
build:
name: Build APK
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v3
with:
gradle-version: '8.4'
- name: Build debug APK
run: gradle assembleDebug --no-daemon
working-directory: evershelf-scale-gateway
- name: Rename APK
run: |
mkdir -p artifacts
cp evershelf-scale-gateway/app/build/outputs/apk/debug/app-debug.apk artifacts/evershelf-scale-gateway.apk
- name: Get version name
id: version
run: |
VERSION=$(grep 'versionName' evershelf-scale-gateway/app/build.gradle.kts | grep -oP '"\K[^"]+')
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
- name: Delete existing latest release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release delete latest --yes || true
- name: Create GitHub Release and upload APK
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create latest \
--title "EverShelf Scale Gateway v${{ steps.version.outputs.name }}" \
--notes "Download the APK and install it on your Android device (7.0+). Allow installation from unknown sources in settings." \
--latest \
artifacts/evershelf-scale-gateway.apk
+93
View File
@@ -86,3 +86,96 @@ jobs:
else:
print(f'{f}: complete ✓')
"
# ── Auto-merge develop → main ────────────────────────────────────────────
# Runs automatically after ALL checks pass on develop.
# You never need to merge manually again — just push to develop.
auto-merge-to-main:
name: Auto-merge develop → main
needs: [lint-php, lint-js, docker-build, validate-translations]
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout (full history)
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure git bot identity
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Merge develop → main
run: |
LAST=$(git log --oneline -1 origin/develop)
git checkout main
git pull --ff-only origin main
git merge --no-ff origin/develop \
-m "chore: auto-merge develop → main
Triggered by: $LAST"
git push origin main
# ── Auto-create GitHub Release on main ───────────────────────────────────
# Runs after auto-merge succeeds. Reads version from index.html,
# creates a release tag vX.Y.Z if it doesn't exist yet.
# This powers the in-app update badge for self-hosted users.
create-release:
name: Create GitHub Release
needs: [auto-merge-to-main]
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from index.html
id: version
run: |
VER=$(grep -oP 'header-version">v\K[\d.]+' index.html | head -1)
echo "version=v${VER}" >> $GITHUB_OUTPUT
echo "Detected version: v${VER}"
- name: Check if tag already exists
id: tag_check
run: |
if git ls-remote --tags origin "refs/tags/${{ steps.version.outputs.version }}" | grep -q .; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Read CHANGELOG entry for this version
id: changelog
if: steps.tag_check.outputs.exists == 'false'
run: |
VER="${{ steps.version.outputs.version }}"
# Extract the section for this version from CHANGELOG.md
BODY=$(awk "/^## \[?${VER#v}\]?|^## ${VER}/,/^## [0-9]/" CHANGELOG.md | head -50 | tail -n +1 | grep -v "^## [0-9]" || true)
if [ -z "$BODY" ]; then
BODY="See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
fi
# Multiline output
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create release
if: steps.tag_check.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.version }}
name: "EverShelf ${{ steps.version.outputs.version }}"
body: ${{ steps.changelog.outputs.body }}
target_commitish: main
make_latest: true
+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'
+20 -1
View File
@@ -11,7 +11,11 @@ data/cron.log
data/smart_shopping_cache.json
data/bring_token.json
data/bring_catalog.json
data/dupliclick_token.json
data/bring_migrate_ts.json
data/shopping_price_cache.json
data/anomaly_dismissed.json
data/opened_shelf_cache.json
data/shopping_name_cache.json
data/client_debug.log
data/rate_limits/
@@ -31,3 +35,18 @@ Thumbs.db
*~
.idea/
.vscode/
# Raw screenshots (working folder, not for distribution)
/screenshots/
# Android kiosk build artifacts (generated by Gradle — not source)
evershelf-kiosk/.gradle/
evershelf-kiosk/app/build/
evershelf-kiosk/build/
evershelf-scale-gateway/app/build/
evershelf-scale-gateway/build/
evershelf-kiosk/local.properties
data/error_reports.log
data/latest_release_cache.json
data/food_facts_cache.json
data/category_ai_cache.json
+7
View File
@@ -9,3 +9,10 @@ RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^api/(.*)$ api/index.php?action=$1&%{QUERY_STRING} [L,QSA]
AddType application/x-x509-ca-cert .crt
# Prevent caching of JS/CSS so kiosk always gets fresh files
<FilesMatch "\.(js|css)$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
+266 -1
View File
@@ -2,9 +2,274 @@
All notable changes to EverShelf will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.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).
## [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
### Fixed
- **"Use first" banner showed a calculated expiry date** — `_renderUseExpiryHint` was displaying a *calculated* shelf-life date (from opening date) instead of the actual one. When `opened_at` is set, the banner now shows "That one [in the fridge], opened X days ago — use it first!" using the new `use.expiry_warning_opened` translation key.
- **"Use All / Done" in recipes deleted the inventory row** — `submitRecipeUse(true)` was sending `use_all: true` to the API, which executed a direct `DELETE` on the inventory row without any confirmation. The function now calculates the exact quantity from the available items (`_recipeUseContext.items`) and sends a regular `inventory_use` with an explicit quantity.
- **Recipes: `qty_number` returned in grams for piece-counted (`pz`) items** — The AI prompt and PHP post-processing now instruct Gemini to express `qty_number` as whole pieces for ingredients with unit `pz` (sliced bread, crackers, etc.). The ingredient list in the prompt includes `[use whole PIECES]` for each `pz` product. The PHP fallback for `pz` items without `default_quantity` no longer divides by 100, but uses the AI-returned `qty_number` if it is a plausible count, otherwise defaults to 1.
### Added
- **Translation key `use.expiry_warning_opened`** — New key in `it.json`, `en.json`, `de.json` with `{loc}` (location) and `{when}` (days since opening) placeholders.
## [1.7.11] - 2026-05-12
### Added
- **Scan page redesign** — The scanner page has been completely redesigned for tablet and mobile:
- **2× fixed zoom** — hardware zoom if available, otherwise automatic CSS `scale(2)`.
- **Torch** — in-viewport button with toast feedback and visual state indicator.
- **Camera flip** — front/back switch with persistence in settings.
- **3 input tabs** — Barcode / Name / AI for quick access to each scanning mode.
- **Recent products** — chips for the last 6 scanned products (localStorage), with category icon.
- **Live code overlay** — partially detected barcode shown as overlay in the viewport during partial scan.
- **Confirm overlay** — checkmark + product name displayed for 900 ms on successful recognition.
- **Guide corners** — visual alignment frame for barcode centering.
- **AI Number OCR** — after 4 s without a scan, a "Read numbers with AI" button appears; Gemini analyses the video frame and returns barcode digits even when the optical scanner fails.
- **PHP `gemini_number_ocr` endpoint** — New POST endpoint; accepts a base64 JPEG image, asks Gemini to locate the EAN-13 / EAN-8 code printed on the product, and returns the digits or `not_found`.
### Fixed
- **False consumption anomaly positives (e.g. "Mozzarella 3 pcs")** — Removed the `untracked` direction (consumption higher than recorded purchases), which was generating banners for every product with untracked purchase history. Only `phantom` and `missing` anomalies are now reported.
- **"~0 g/week" consumption prediction** — The model now requires a minimum of 5 transactions (was 3) and a time span of at least 7 days; predictions where consumption is < 15% of the baseline are skipped, eliminating false positives for products with few closely-spaced transactions.
- **Suggestion dropdown on the Name field (scan page)** — Removed `list="common-products"` from the input field; the datalist is no longer triggered on tablets.
## [1.7.10] - 2026-05-11
### Fixed
- **"Set expiry" banner did nothing** — `editBannerNoExpiry()` was calling `openEditInventoryModal()` which does not exist. Fixed to call `editInventoryItem()` (the correct function used by all other banner handlers). Added a prefetch of `inventory_list` because `currentInventory` is empty on the dashboard.
- **"Product not found" when opening modal from a banner** — `currentInventory` is always empty on the dashboard; the inventory fetch now happens before opening the modal (same pattern as `editReviewItem` and `weighBannerItem`).
- **Expired banner on opened UHT milk** — The banner was showing "Expired!" instead of "Opened too long". Items with `opened_at` now display "Opened X days ago in [location]" in both the title and the banner detail.
- **Generic milk shelf life 4 → 7 days** — Milk without qualifiers (e.g. "Milk") was treated as fresh (4 days). Fresh milk is still handled explicitly (`latte fresco/intero/parzial/scremato` → 3 days); the generic case now defaults to 7 days (UHT default). Fix applied in both PHP (`database.php`) and JS (`app.js`).
- **Stale `opened_at` on sealed packages after split** — When a use operation splits a row into "whole sealed packages + opened fraction", the sealed-packages row was not clearing `opened_at`. All 3 split code paths now execute `opened_at = NULL` on the sealed row.
- **`inventory_update` was not recording transactions** — The quantity-edit modal updated inventory without creating transaction records. The quantity difference is now automatically recorded as `in` or `out` with a `[Manual correction]` note, preventing false positives in the anomaly detector.
- **False consumption anomalies after restocking** — The prediction baseline was using only the restock quantity (`restockQty`), ignoring pre-existing stock, causing `actual > expected` systematically. New baseline: `current_qty + consumed_since_last_restock`, which correctly reflects the real situation regardless of prior stock levels.
- **Anomaly banner firing on almost all products** — Two fixes:
1. `expected = 0` no longer generates a "more" anomaly (the model assumed you should have run out, but you restocked).
2. "More than expected" threshold raised to 400% (was 30%); "less than expected" threshold remains at 30%.
- **Expired section showing already-discarded products** — The `expired` query was missing `AND i.quantity > 0`; discarded products (qty=0) with a past expiry kept appearing. Query fixed and orphan rows cleaned from the DB.
- **Hardcoded Italian string `scade il` in banner** — Replaced with the correct i18n key.
- **Docker: `SQLSTATE[HY000][14] unable to open database file`** — `_ensureDataDir()` in `database.php` now creates the `data/` directory if missing and attempts `chmod(0775)` if not writable, resolving the error on freshly mounted Docker volumes.
### Added
- **Complete i18n** — Added ~25 missing translation keys for kiosk UI, Gemini responses, banners, scanner, shopping, and appliances across all 3 language files (`it.json`, `en.json`, `de.json`). Total: 934 keys per language.
## [1.7.8] - 2026-05-10
### Added
- **Transfer to Recipes from chat** — When the Gemini Chef chat generates a recipe, a "📥 Transfer to Recipes" button appears. Pressing it triggers Gemini to convert the chat text into a complete structured JSON (title, meal, ingredients, steps); the backend enriches each ingredient with `product_id` and `location` via fuzzy-match (identical to `generateRecipe`); the recipe is saved and opens directly in the Recipes section with all "Use" buttons and full cooking mode.
- **"Open recipe" button** — After a successful transfer, the "📥 Transfer to Recipes" button transforms into "📖 Open recipe" (same DOM element), preventing overlap.
- **Create a recipe from an ingredient** — In the action panel of every inventory item, a "👨‍🍳 Create a recipe with this" button appears (teal, full width). Pressing it, Gemini generates a recipe using that ingredient as the star (same pipeline as `chatToRecipe`: inventory fuzzy-match enrichment, `meal=null`, 8192 token max).
- **Meal not auto-categorized** — Recipes generated from chat or from an ingredient are no longer auto-categorized (`meal` remains null); the meal tag in the UI is only shown when explicitly set.
### Fixed
- **Smart shopping: false "running low" alert** — If a product in grams/ml was nearly exhausted (e.g. Butter 30 g = 12%) but the same product was also available as a sealed package (Butter 1 pack = 99%), the system still flagged "running low". Now checks whether the `shopping_name` family has stock from other products; if so, the alert is suppressed.
- **Corrupted translation JSON** — The `action` section was duplicated in `de.json`, `en.json`, and `it.json`, causing JSON parse errors that blocked CI/CD. The spurious duplicate section has been removed.
## [1.7.7] - 2026-05-10
### Fixed
- **Smart shopping family suppression** — The `recentlyExhausted` logic (products finished < 14 days ago) was incorrectly bypassing the `shopping_name` family suppression, causing false positives: products like Vanilla Yogurt appeared urgent even with 2 kg of Yogurt in stock. `recentlyExhausted` now only bypasses the token-based loose match; family suppression by `shopping_name` always applies.
- **Shelf-life pre-warming in cron** — The cron now calls `prewarmShelfLifeCache()` every 5 minutes, pre-loading via Gemini AI the shelf life of opened inventory items (max 5 items per cycle) before the user views them. This eliminates the noticeable delay on first click of "Opened on…".
## [1.7.6] - 2026-05-10
### Fixed
- **`shopping_name` truncated (Piadina)** — The product "Piadine medie" had `shopping_name='Pi'` (truncated), preventing it from grouping correctly in its family. Fixed to `Piadina`.
- **Family merges in DB** — Grana Padano now under `Formaggio` (was a `Grana` singleton), Prosciutto cotto now under `Affettato`, Panna acida now under `Panna`.
- **`daily_rate` over the actual active period** — The daily consumption rate was using `first_in → now` as the window, diluting the rate with periods when the product was already exhausted (e.g. garlic exhausted at day 34 was calculated over 60+ days). Now uses `first_in → last_activity` (last purchase or last use), giving more accurate reorder predictions.
- **Stable anomaly dismiss key** — The dismiss key was using `product_id + round(expected)`, which changed with every new transaction, causing already-dismissed anomalies to reappear. Now uses `product_id + direction` (phantom/missing/untracked) — stable as long as the direction does not change.
- **Smart shopping: products exhausted < 14 days ago** — Products finished within the last 14 days are no longer suppressed by the token-coverage check or the shopping_name family check: if you just ran out, you probably want to restock regardless of equivalent stock on hand.
- **Chat pruning** — `chatSave()` now deletes messages beyond the 200 most recent after each save, preventing unbounded growth of the `chat_messages` table.
## [1.7.5] - 2026-05-10
### Added
- **Vacuum sealed prompt on item use** — After using a conf/weighted-unit item that still has remaining stock, a sliding popup asks "🔒 Messo sotto vuoto?" with Sì/No buttons and an 8-second auto-dismiss countdown bar. Default is Sì if the item was previously sealed, No otherwise. Works for all container units (conf, g, kg, ml, l) and any item previously marked as vacuum sealed.
- **Multi-function appliance awareness in recipes** — When the user sets a multi-function appliance (Cookeo, Bimby, Thermomix, Monsieur Cuisine, Instant Pot, Multicooker, Robot da cucina) in Settings, all Gemini recipe prompts (chat, recipe generation, weekly meal plan) now explicitly instruct the AI to consolidate as many cooking steps as possible into that single machine. Each appliance's available functions (rosolare, tritare, vapore, cuocere a pressione, etc.) are listed and the AI is required to indicate the specific mode/program at each step.
- **Server-side Bring! cleanup in cron** — `bringCleanupObsolete()` now runs every 5 minutes via cron without requiring any client page load. Items auto-added by the app (identified by `⚡`/`🟠`/`🛒` markers in their Bring! spec) are automatically removed when the smart shopping engine no longer flags them as needed. Works across all devices/clients.
- **`shopping_name` in `inventory_list` API** — The `inventory_list` endpoint now returns the `shopping_name` field from the products table, enabling family-based stock matching in the client-side cleanup fallback.
### Fixed
- **Bring! cleanup: false token match (Succo/Frutta)** — `bringCleanupObsolete` previously indexed smart items by product name tokens. "Pera Italiana **Succo** e polpa **frutta**" (shopping_name: "Pere") caused "Succo" and "Frutta" to be retained on Bring! indefinitely even when fully stocked. Now indexes **only** by `shopping_name` tokens.
- **Bring! cleanup: expired items with fresh family stock (Verdure)** — When a product is expired but its `shopping_name` family has ≥50% fresh stock from other products (e.g. Minestrone tradizione scaduto 01/05 but 590g fresh Verdure in freezer/pantry), it is no longer flagged as `critical` and is removed from the shopping list.
- **Bring! remove: catalog items not removed (Formaggio/Käse)** — `bringRemoveItem()` and `bringCleanupObsolete()` now try both the Italian display name and the Bring! internal German catalog key (e.g. `Käse` for `Formaggio`). Previously, catalog items with a German key were silently not removed.
- **Barcode scanner: EAN auto-submit on manual input** — Typing or pasting a valid 8/13-digit EAN in the manual barcode field now auto-submits immediately without needing to press a button. Checksum validation gives a warning toast for invalid codes without blocking entry.
- **Shopping list: `isExpiringSoon` false positives** — Products bought in bulk that expire naturally in 3 days (e.g. fresh produce) were flagged `medium` urgency on the shopping list despite having 100%+ stock. Now requires `pctLeft < 50%` before triggering.
- **Shopping list: expired batch with fresh restock suppressed** — Products with an expired batch AND a recent fresh restock (≥50% fresh stock) are no longer flagged `critical` for shopping. The expired-batch UI banner on the dashboard handles the disposal prompt instead.
- **Shopping list: cross-device cleanup** — Client-side `cleanupObsoleteBringItems()` now detects app-added items by their spec markers (`⚡`/`🟠`/`🛒`) instead of a per-device localStorage map, making cleanup work correctly on all clients including newly logged-in devices. Throttle reduced from 30 minutes to 3 minutes.
- **API fetch caching disabled** — All `api()` calls in the frontend now set `cache: 'no-store'` to prevent stale data from browser cache.
- **Shopping page multi-client sync** — Added 45-second polling on the shopping page so changes made on another device are reflected automatically.
### Added
- **AI price estimation for shopping list** — Each item on the Bring! shopping list now shows an estimated retail price badge (per unit and total). Prices are fetched from Gemini AI and cached server-side for 3 months (`PRICE_UPDATE_MONTHS`). The running estimated total is displayed both in the shopping tab and as a green pill badge on the dashboard stat card.
- **Dashboard price total badge** — The shopping stat card on the dashboard shows a green `ca. €X.XX` badge (top-right, same position as the old urgency badge). It updates in real-time as prices are calculated and persists across navigation via `sessionStorage`.
- **Background price refresh** — Prices are fetched silently every 2 minutes even when not on the shopping tab, keeping the dashboard badge current without user interaction.
- **Smart quantity estimation** — The price payload uses `smart_shopping` data (consumption patterns) to send the correct buy quantity per item; falls back to Bring! spec parsing, then to `qty=1, unit=conf` for manually-added items.
### Fixed
- **`stat-price-total` not visible on dashboard** — The total was only computed when `shoppingItems` was populated (i.e. shopping tab had been visited). Now uses `sessionStorage._pricetotal` as fallback so the badge is visible immediately on any page.
- **Price bar reloading on every tab switch** — `renderShoppingItems` now checks if ALL items are already cached with matching qty/unit; if so, it applies prices from cache instantly with no loading bar or API call.
- **`stat-price-total` real-time update** — Dashboard stat now increments as each individual item is priced (not only after the entire fetch completes).
- **Broken emoji in `log.title`** — Corrupted `\uFFFD` character in `it.json` and `de.json` replaced with `📒`.
- **`PRICE_CACHE_PATH` undefined crash** — Server-side constant was used inside functions that were called before the define; moved define to the very top of `api/index.php` (line 19). Affected: all `get_shopping_price` and `get_all_shopping_prices` calls from 16:3316:40 on 2026-05-07.
## [1.7.1] - 2026-05-04
### Fixed
- **Destructive actions now require confirmation** — "Butta tutto" (`throwAll`) and "Finisci tutto" (`submitUseAll`) now display a confirmation modal before executing. The modal features a 5-second auto-confirm countdown bar (red) with an "Annulla" cancel button, matching the scale auto-confirm UX pattern already in use.
- **History undo button visibility** — The ↩ undo button in the transaction log was using `color: var(--text-muted)` making it nearly invisible. It now uses a red tint background + border (`#f87171`) with larger font size (1rem) for easy tap targeting.
- **History undo uses custom modal** — `undoTransactionEntry()` previously used the native browser `confirm()` dialog (broken in Android WebView kiosk mode). It now uses the same `_showDestructiveConfirm()` modal with countdown.
### Added
- **Demo mode (JS frontend)** — Full client-side demo experience: Gemini is treated as available, Bring! write operations silently no-op, and a mock pantry + shopping list is shown; activated via `?demo=1` URL param or `.env` `DEMO_MODE=true`; a "DEMO" badge is injected in the header and Settings is hidden to prevent accidental writes
- **Graceful Bring! no-key state** — When Bring! credentials are not configured the shopping tab shows a friendly localised message with a direct link to the Settings page instead of a raw API error
- **Use-quantity guard** — Consuming more than the quantity stocked at the selected location is now blocked before the API call; the quantity input shakes (CSS `input-shake` animation) and a toast shows `use.error_exceeds_stock`
- **Kiosk: smart auto-discovery rewrite** — `autoDiscover()` now uses `ExecutorCompletionService` + `NetworkInterface` (replaces deprecated `WifiManager`), 60 parallel threads, 600 ms TCP pre-check per host, real-time UI feedback every 120 ms, ports `[443, 80, 8080, 8443]`; VPN/cellular interfaces (tun, ppp, rmnet, pdp, ccmni, etc.) are filtered out and `wlan*`/`eth*` interfaces are prioritised
- **Kiosk: permissions button transform** — After permissions are granted, the button changes to "✅ Permessi concessi — Continua →" (green background, dark text) and advances to step 3 on tap, replacing the separate "permissions granted" card
- **Kiosk: gateway auto-pre-configuration** — On successful gateway install `finishSetup()` POSTs `scale_enabled=true` + `scale_gateway_url=ws://127.0.0.1:8765` to the server's `save_settings` endpoint so the webapp is scale-ready immediately after setup
- **Kiosk: ErrorReporter init at setup start** — `SetupActivity.onCreate()` now calls `ErrorReporter.init()` with any previously saved URL, ensuring errors in step 4 (gateway install) are reported even before the user confirms the server URL
### Fixed
- **Kiosk: wrong subnet scanned** — The previous implementation picked up VPN/tun interfaces and scanned a 10.x.x.x range instead of the device's actual Wi-Fi LAN; fixed by filtering interface names and preferring `wlan`/`eth`
- **Kiosk: port 443 missing from discovery** — HTTPS servers were never reachable during auto-discovery; ports list extended to `[443, 80, 8080, 8443]`
- **Kiosk: gateway install status=1 silent failure** — `PackageInstaller.STATUS_FAILURE` (status 1) showed an error card but never called `ErrorReporter`; `ErrorReporter.reportMessage()` is now called with status code, message, and package name
- **Screensaver toggle in web settings** — The screensaver row was missing a `<span class="toggle-slider">` inside the `<span class="toggle-switch">` wrapper, so no slider was rendered; corrected to use the same `toggle-row` / `toggle-switch` / `toggle-slider` structure as all other settings toggles
- **antiwaste.title translation** — IT and DE locale files were missing the `antiwaste.title` key, causing a raw key string to appear in the anti-waste section header; added to both `it.json` and `de.json`
### Kiosk (v1.4.0 → v1.5.0)
- `autoDiscover()` fully rewritten (CompletionService, NetworkInterface, TCP pre-check, real-time feedback, correct LAN subnet)
- Port 443 added to discovery scan
- Permissions button transforms after grant (`onPermissionsGranted()`)
- `ErrorReporter.init()` called at `SetupActivity.onCreate()`
- `ErrorReporter.reportMessage()` called on gateway install failure
- `finishSetup()` pre-configures gateway via `save_settings` API call
## [1.6.0] - 2026-05-03
### Added
- **Dashboard skeleton loading** — Stat cards (Dispensa / Frigo / Freezer) show an animated shimmer placeholder (`…`) instead of the jarring `0` flash that appeared for 35 seconds before data loaded; the loading class is applied before the API call and removed atomically when data arrives
- **Webapp startup preloader** — Full-screen spinner overlay during initial app load, fades out after the dashboard is ready
- **Webapp update notification** — A dismissible top banner alerts the user when a newer GitHub release is available (checked once every 6 hours, comparison based on `published_at`)
- **Native Android update banners** — Both Kiosk (v1.4.0) and Scale Gateway (v2.1.0) show a native top bar when a newer APK is available, with one-tap download and install
### Fixed
- **APK install conflict** — Replaced `ACTION_VIEW`-based APK install with the `PackageInstaller.Session` API (API 21+) in both Kiosk and Scale Gateway; the session-based approach correctly handles:
- `STATUS_PENDING_USER_ACTION` → automatically launches the system confirmation dialog
- `STATUS_SUCCESS` → success toast
- `STATUS_FAILURE_CONFLICT` / `STATUS_FAILURE_INCOMPATIBLE``AlertDialog` offering to uninstall the old app (signature mismatch) before reinstalling
- **Cooking mode z-index** — Update banner and app header are now hidden when `body.cooking-mode-active` is set, and the cooking overlay z-index was raised to `99998` so it can no longer be obscured by UI chrome
- **Version-aware error reporting** — GitHub Issues are only created when the client is running the latest released version, avoiding noise from stale deployments; non-semver tag names (e.g. `"latest"`) are treated as "always up-to-date"
- **XOR-obfuscated GitHub token** — The PAT used for GitHub API calls is stored as an XOR-encoded hex string in both the PHP backend and Kotlin apps to prevent accidental exposure via secret scanning
### Kiosk (v1.3.0 → v1.4.0)
- FileProvider + `REQUEST_INSTALL_PACKAGES` permission added
- APK download destination moved to `getExternalFilesDir(null)` (no storage permission needed)
- `PackageInstaller` self-update with signature-conflict recovery
- BLE scale gateway update banner with download + install flow
### Scale Gateway (v2.0.0 → v2.1.0)
- Same FileProvider + permission + `PackageInstaller` changes as Kiosk
- Update banner for self-update
- CI workflow now triggers on `develop` branch (in addition to `main`)
## [Unreleased] - 2026-04-30
### Fixed
- **Low-qty banner false positive** — A "suspiciously low quantity" review alert is now suppressed for a partially-used inventory entry when one or more sibling entries for the same product (identified by barcode, or name+brand as fallback) exist in other locations with stock > 0. Prevents noise like "191 ml of milk" when 11 sealed packages are stored in the pantry.
### Changed
- **Non-alarmist expired banner** — Banner icon, CSS class, and title suffix now adapt to the `getExpiredSafety()` level:
- `ok` (long-life products, freezer within margin): green banner, ✅ icon, "— Scaduto (ancora ok)"
- `warning` (items that should be inspected): amber/yellow banner, 👀 icon, "— Scaduto (controlla)"
- `danger` (raw meat, dairy, fish, etc.): unchanged red 🚫 banner and "— Scaduto!" title
- Added `expiry.expired_suffix_ok` and `expiry.expired_suffix_warning` i18n keys to all three language files (IT/EN/DE)
- Added `banner-expired-ok` and `banner-expired-warning` CSS variants (green / amber) in `style.css`
## [1.5.0] - 2026-04-28
### Added
- **Expired banner for opened products** — Products whose opened-product shelf-life has passed (e.g. fridge cream opened 6 days ago) now appear in the top notification banner, not just the dashboard list
- **Safety-aware expired banner** — Each expired banner item shows a contextual safety tip (from `getExpiredSafety()`); danger-level items (fridge dairy/meat/fish) get an intense red banner and "L'ho buttato" as the primary button; safe/warning items keep the original button order
- **AI model fallback** — All Gemini API endpoints (expiry scan, product identification, chat, recipe non-streaming, shopping name classifier) now try `gemini-2.5-flash` first and fall back to `gemini-2.0-flash` automatically, matching the resilience already in place for recipe streaming
- **Friendly AI quota message** — When the AI returns a quota/rate-limit error the user sees "Quota AI esaurita. Riprova tra qualche minuto." instead of the raw API error string
- **Cooking TTS auto-read** — Each recipe step is read aloud automatically when navigating forward or backward; the first step is also read when entering cooking mode
- **Cooking timer 10-second warning** — When a cooking timer reaches 10 seconds the TTS announces "Attenzione! [label]: mancano 10 secondi!"
- **Cooking recipe completion announcement** — "Ricetta completata! Buon appetito!" is spoken via TTS when the last step is confirmed
### Fixed
- **Cooking TTS gate** — `speakCookingStep()` was blocked by the global `tts_enabled` setting; the `_cookingTTS` toggle (🔊/🔇 button) is now the only gate; browser Web Speech API is used by default without requiring TTS configuration in Settings
- **Anomaly dismiss label** — The "La quantità è giusta" button now appends the current inventory quantity, e.g. "La quantità è giusta (2 pz)", so the action is unambiguous
- **i18n sync** — Added `timer_warning_tts`, `recipe_done_tts`, `error.ai_quota` keys to all three language files (IT/EN/DE)
### Added
- **Generic shopping names** — Products are grouped by type ("Latte", "Affettato", "Pasta") rather than brand; computed via an expanded keyword map with Google Gemini AI as fallback for unknown products
- **Bring! auto-migration** — Existing list items with old specific names are silently migrated to generic names on every list load, throttled to once per 10 minutes
- **Bring! catalog coverage** — All 93 shopping_name values now resolve to a German Bring! catalog key (icons and categories in the Bring! app); 24 aliases added to cover previously unmatched names
- **Auto-add to Bring! on depletion** — When a product reaches zero the app adds it to Bring! automatically using the generic shopping name, with the specific product name and brand in the specification field
- **Finished-product confirmation banner** — Instead of silently deleting zero-stock entries, a banner prompts the user to confirm; banner title includes the last 3 digits of the product barcode for easier identification
- **Anomaly detection banner** — Dashboard notifications for suspicious inventory/transaction mismatches and consumption prediction errors, with one-tap inline correction
- **SSE recipe streaming** — Recipe generation streams live via Server-Sent Events; Gemini agent feedback is shown in real time as it is generated
- **Smart alert banners** — Configurable expired-only mode with explanatory messages; banner buttons are fully internationalized
### Fixed
- **Scale double-deduction** — Multiple BLE stable readings of the same weight no longer fire duplicate `inventory_use` events; JS preserves the confirmation sentinel on submit and PHP rejects a second `out` transaction for the same product within 12 seconds
- **Kiosk native TTS** — CI workflow now builds the APK on `develop` branch too; the native Android `TextToSpeech` bridge bypasses Web Speech API voice-availability issues without requiring offline voice packs
- **TTS voice loading** — Retries for up to 10 seconds on page load; shows a message if no voices are available and offers a manual refresh button
- **Bring! migration** — Corrected two bugs: wrong removal API (`DELETE /item``PUT remove=item`) and wrong purchase key sent to Bring! (Italian shopping name → German catalog key), which previously created Italian/German duplicate entries
- **Gemini 429 rate limiting** — API calls are retried with exponential backoff; recipe requests are capped at 5 per minute with a dedicated rate-limit bucket
### Performance
- **Gemini calls centralized** — All Gemini API requests go through a single `callGemini()` helper with intelligent backoff; Gemini removed from the product-selection and bringSuggest flows in favour of fast offline logic
## [1.3.0] - 2026-04-18
### Added
- **Expired product banner** — Dashboard notifications for expired products with use, throw away, edit, and dismiss actions
- **Expiring soon banner** — Dashboard notifications for products expiring within 3 days with use, edit, and dismiss actions
- **Priority-sorted notifications** — Banner alerts sorted by urgency: expired > expiring > suspicious quantities > consumption predictions
- **Swipe navigation** — Touch swipe left/right to browse banner notifications, with dot indicators and arrow buttons
- **Quick-access buttons** — Inventory page shows 4 recently used and up to 8 most popular products for quick selection
- **Recent & popular products API** — New `recent_popular_products` endpoint
- **Auto-refresh** — Banner notifications refresh every 5 minutes while on the dashboard
- **Edit from expiry banner** — Correct expiry dates directly from expired/expiring notifications
### Fixed
- **Negative scale values** — BLE scale readings with negative weight are now ignored
- **Banner re-appearing after edit** — Editing from a banner now persists the confirmation so it doesn't reappear on dashboard reload
- **False consumption predictions** — Manual inventory edits (updated_at > last restock) now use the correct baseline for prediction calculations
- **Kiosk overlay blocking header** — Removed injected exit/refresh buttons from the web app header in kiosk mode
## [1.2.0] - 2026-04-13
### Changed
+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.
+10 -6
View File
@@ -1,15 +1,19 @@
FROM php:8.2-apache
FROM php:8.2-apache-bookworm
# Install required PHP extensions
RUN apt-get update && apt-get install -y \
# Install required PHP extensions + Tesseract OCR for offline expiry date reading
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
libsqlite3-dev \
libcurl4-openssl-dev \
libonig-dev \
&& docker-php-ext-install pdo_sqlite curl mbstring \
libgd-dev \
tesseract-ocr \
tesseract-ocr-ita \
tesseract-ocr-eng \
&& docker-php-ext-install pdo_sqlite curl mbstring gd \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Enable Apache mod_rewrite
RUN a2enmod rewrite
# Enable Apache mod_rewrite and mod_headers
RUN a2enmod rewrite headers
# Set working directory
WORKDIR /var/www/html
+138 -26
View File
@@ -2,17 +2,37 @@
> **Self-hosted pantry management system** — Track your food inventory, scan barcodes, get AI-powered recipe suggestions, and reduce waste.
---
<div align="center">
### 🚀 Try the live demo — no installation required!
**[▶ Open Live Demo](https://evershelfproject.dadaloop.it/demo)**
&nbsp;·&nbsp;
[🌐 Project Website](https://evershelfproject.dadaloop.it/)
&nbsp;·&nbsp;
[📖 Wiki](https://github.com/dadaloop82/EverShelf/wiki)
*The demo runs with mock pantry data. AI features are fully enabled. All write operations are safely sandboxed.*
</div>
---
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![PHP](https://img.shields.io/badge/PHP-8.0+-blue.svg)](https://www.php.net/)
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.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)
<!--
<p align="center">
<img src="assets/img/screenshots/dashboard.png" alt="Dashboard Screenshot" width="320" />
</p>
-->
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/J3J01ZNETZ)
---
@@ -20,42 +40,86 @@
### 📦 Inventory Management
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS
- **AI identification** — Take a photo and let Google Gemini identify the product
- **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory; gracefully shows a friendly message when AI quota is exhausted instead of a raw API error
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section)
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("La quantità è giusta (2 pz)")
### 🤖 AI-Powered (Google Gemini)
- **Expiry date reading** — Photograph a label and extract the expiry date automatically
- **Product identification** — Point your camera at any product for instant recognition
- **Recipe generation** — Get personalized recipes based on what's in your pantry
- **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones
- **Storage & shelf-life hint** — When adding a new product, Gemini suggests the optimal storage location and shelf-life in the background; shown as an inline AI badge next to the expiry estimate
- **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated
- **Smart chat assistant** — Ask questions about your inventory, get cooking tips
- **Shopping suggestions** — AI-powered purchase recommendations
- **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip
- **Anomaly explanation** — "🤖 Spiega" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
- **Model fallback** — All AI endpoints try `gemini-2.5-flash` first and fall back to `gemini-2.0-flash` automatically
- **Graceful no-key state** — When no Gemini key is configured, AI entry points show a friendly message; the header button is visually greyed with an amber dot
### 🛒 Shopping List
- **Bring! integration** — Sync with the [Bring!](https://www.getbring.com/) shopping list app
- **Generic shopping names** — Products are grouped by type ("Latte", "Affettato", "Panna da cucina") rather than brand, keeping the Bring! list clean and consolidated
- **Smart predictions** — Know what you'll need before you run out
- **Auto-remove on scan** — Products are removed from the shopping list when scanned in
- **DupliClick integration** — Online grocery ordering (Gruppo Poli)
- **Auto-add on depletion** — When a product reaches zero the app adds it to Bring! automatically, no confirmation needed
- **Auto-remove on scan** — Products are removed from the shopping list when scanned in - **Auto-migration**Items already on the Bring! list are silently renamed to their generic name in the background (throttled, runs on list load)
- **Catalog coverage** — All product types resolve to a German Bring! catalog key for icon and category display in the Bring! app
### 🍳 Cooking Mode
- **Step-by-step guidance** — Follow recipes with a hands-free cooking interface
- **Text-to-Speech** — Voice readout of recipe steps (configurable TTS endpoint)
- **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled
- **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode
- **Timer voice alerts** — 10-second countdown warning spoken aloud before each timer expires; expiry announced vocally when time is up
- **Recipe completion** — "Buon appetito!" spoken when the last step is confirmed
- **Built-in timer** — Automatic timer suggestions based on recipe instructions
- **Ingredient tracking** — Mark ingredients as used during cooking
- **Ingredient tracking** — Mark ingredients as used during cooking; leftover quantities prompt a "move to another location" flow
### 📊 Dashboard
- **Waste tracking** — Monitor consumed vs. wasted products over 30 days
- **Anti-waste report** — Personalised waste rate vs. national average with annual kg estimate; shown above the expiring-items list
- **Expiry alerts** — Visual warnings for expired and soon-to-expire items
- **Safety ratings** — Smart assessment of expired product safety (by category)
- **Opened products panel** — Tracks partially-used items; expiry is recalculated from the opening date using AI (Gemini) + per-category rule fallback; whole sealed packages always keep their original manufacturer expiry; conf items with mixed whole + fractional units are shown as two separate entries
- **Freezer shelf-life** — Granular per-product estimates (USDA/EFSA): fish 120 d, poultry 270 d, whole red-meat cuts 365 d, mince 120 d, vegetables/fruit 270 d, generic 180 d; AI + cache still take priority over rules
- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and "L'ho buttato" as the primary action
- **Expired product banner** — Products that have passed their effective shelf-life (including opened-product reduced expiry) appear in the top notification banner; icon, colour and title adapt to the actual safety level (✅ green for safe, 👀 amber to check, 🚫 red for danger); high-risk items get a prominent discard action
- **Quick recipe bar** — One-tap recipe suggestion using expiring products
- **Anomaly banner** — Scrollable banner with suspicious quantities and consumption prediction mismatches, with one-tap correction or inline edit
- **Expired/expiring alerts** — Priority-sorted banner notifications for expired and soon-to-expire products with use, throw, edit, and dismiss actions
- **Swipe navigation** — Touch swipe or tap arrows/dots to browse banner notifications
- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access
### 📱 Progressive Web App
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
- **Installable** — Add to home screen for a native app experience
- **Multi-device** — Settings and data sync across devices on the same server
### ⚖️ Smart Scale Integration (Add-on)
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
- **Auto-discovery** — Server scans LAN to find the gateway automatically
- **Auto weight reading** — When adding/using a product with unit g/ml, weight fills automatically
- **10g threshold** — Ignores readings that haven't changed enough between products - **Duplicate-reading prevention** — Server-side 12-second dedup window rejects a second scale-triggered deduction of the same product, guarding against BLE multi-fire- **ml conversion hint** — Shows "weight in grams → will be converted to ml" when product unit is ml
- **Stability + auto-confirm** — 10s stable wait + 5s countdown before confirming
- **Real-time status** — Scale connection indicator always visible in the header
- **Multi-protocol** — Supports Bluetooth SIG Weight Scale, Body Composition, Xiaomi Mi Scale 2 and 100+ models
- **Built into kiosk (v1.6.0+)** — BLE gateway runs as an integrated foreground service inside the [EverShelf Kiosk](evershelf-kiosk/) app; no separate APK needed.
### 📺 Android Kiosk Mode (Add-on)
- **Dedicated tablet app** — Full-screen WebView wrapper for wall-mounted kitchen tablets
- **True kiosk lock** — Screen pinning blocks home/recent buttons
- **Setup wizard** — 6-step guided configuration (language, welcome, permissions, server URL, BLE scale scan, screensaver, summary)
- **Smart auto-discovery** — Scans the LAN in parallel (60 threads, TCP pre-check, ports 80/443/8080/8443) with real-time UI feedback; correctly identifies the device's Wi-Fi/Ethernet subnet (VPN and cellular interfaces are filtered out)
- **Built-in BLE scale gateway** — `GatewayService` foreground service; BLE scanning + WebSocket server `:8765` run directly inside the kiosk app. Select your scale in step 5 of the wizard — no external app required
- **Scale auto-configuration** — After selecting the BLE device, the wizard writes `scale_enabled` and `scale_gateway_url=ws://127.0.0.1:8765` to the server automatically
- **Camera & mic permissions** — Full hardware access for barcode scanning and voice; grant button transforms to a green confirmation after granting
- **Native TTS bridge** — Cooking mode voice readout uses the Android TextToSpeech engine directly, bypassing Web Speech API voice limitations; no offline voice packs required
- **Hard refresh** — ↻ button clears WebView cache to pick up web app updates
- **Update notifications** — Checks GitHub releases every 6h, shows banner when updates available
- **SSL support** — Accepts self-signed certificates
- **Android kiosk app** — [`evershelf-kiosk/`](evershelf-kiosk/) — downloadable APK
---
## 🚀 Quick Start
@@ -117,6 +181,13 @@ BRING_PASSWORD=your_password
TTS_URL=http://your-home-assistant:8123/api/events/tts_speak
TTS_TOKEN=your_long_lived_token
TTS_ENABLED=true
# Optional: Security — protect the save_settings endpoint
# Set a strong random string; the Settings UI will ask for it before saving
SETTINGS_TOKEN=
# Optional: Demo mode — block all write operations at the router level
DEMO_MODE=false
```
### Web Server Configuration
@@ -214,6 +285,14 @@ evershelf/
├── evershelf.db # SQLite database (auto-created)
├── backups/ # Local DB backups
└── *.json # Token/cache files
evershelf-scale-gateway/ # ⚖️ Android BLE gateway [DEPRECATED — integrated into kiosk v1.6.0+]
├── README.md # Deprecation notice + legacy docs
└── app/src/ # Kotlin Android source (WebSocket + BLE)
evershelf-kiosk/ # 📺 Android kiosk app (add-on)
├── README.md # Setup & feature docs
└── app/src/ # Kotlin Android source (WebView wrapper)
```
### API Endpoints
@@ -232,6 +311,9 @@ evershelf/
| | `gemini_expiry` | POST | Read expiry date from photo |
| | `gemini_chat` | POST | Chat with AI assistant |
| | `generate_recipe` | POST | Generate recipe from inventory |
| | `gemini_product_hint` | POST | Storage location + shelf-life hint |
| | `gemini_shopping_enrich` | POST | Enrich shopping suggestions with tips |
| | `gemini_anomaly_explain` | POST | Plain-language anomaly explanation |
| **Shopping** | `bring_list` | GET | Get Bring! shopping list |
| | `bring_add` | POST | Add items to Bring! |
| | `smart_shopping` | GET | Smart shopping predictions |
@@ -244,10 +326,12 @@ evershelf/
- **Credentials** are stored in `.env` (server-side, never committed to Git)
- **Database** stays local — never pushed to remote repositories
- **API keys** are passed server-side only — never exposed to the browser
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `settings_token_set`), never raw key values
- **Settings write protection** — set `SETTINGS_TOKEN` in `.env` to require a secret token (`X-Settings-Token` header) for all `save_settings` calls; validated with `hash_equals` to prevent timing attacks
- **Demo / public mode** — set `DEMO_MODE=true` to block all write operations at the PHP router level before any business logic runs
- The API uses **parameterized SQL queries** (PDO prepared statements) against injection
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
- Consider adding **authentication** if the server is accessible from the internet
- Consider adding **reverse-proxy authentication** (e.g. Authelia, Nginx `auth_basic`) if the server is accessible from the internet
---
@@ -268,16 +352,35 @@ The application uses no build tools — edit files directly and refresh.
## 📋 Roadmap
- [x] Multi-language support (i18n) — 3 languages (it/en/de), 347 keys
- [ ] User authentication / multi-user support
- [x] Docker container for easy deployment — see [Dockerfile](Dockerfile) + [docker-compose.yml](docker-compose.yml)
- [x] REST API documentation (OpenAPI/Swagger) — see [docs/openapi.yaml](docs/openapi.yaml)
- [x] First-run setup wizard — 4-step guided configuration
- [x] API rate limiting — file-based, 3 tiers (120/15/5 req/min)
- [x] CI/CD pipeline — GitHub Actions (lint, Docker build, translation validation)
- [ ] Offline mode with service worker
- [ ] Export/import inventory data
- [ ] Notification system (Telegram, email) for expiring products
### High Priority
- [ ] **Cooking mode — 3D wheel JS** — swipe navigation, gyroscope tilt, haptic feedback
- [ ] **Cooking mode — step timers** — auto-detect "X minutes" in recipe steps, countdown + alert
- [ ] **Push notifications** — daily expiry alerts via PWA Service Worker + VAPID
- [ ] **Quick search / quick-add bar** — always-visible search above the nav, PWA shortcuts
### Medium Priority
- [ ] **Receipt OCR → bulk add** — photo of receipt → Gemini Vision → auto-fill inventory
- [ ] **CSV/JSON export & import** — download/upload inventory from Settings
- [ ] **Custom storage locations** — user-defined locations beyond Fridge/Freezer/Pantry
- [ ] **Multi-user support** — PIN-based user distinction, action log with user label
- [ ] **AI optimal purchase prediction** — suggest "buy X units of Y within Z days"
- [ ] **Price history sparklines** — per-product price chart from the AI cache data
### Low Priority / Nice to Have
- [ ] **Dark mode** — CSS custom properties are already structured to support it
- [ ] **Full offline mode** — Service Worker cache to show inventory read-only when server is down
- [ ] **French & Spanish translations** (`fr.json`, `es.json`)
- [ ] **Swipe actions on inventory rows** — swipe left to use/discard, right to edit
- [ ] **PHP unit tests** — PHPUnit coverage for shelf-life, price calc, and key helpers
### Completed ✅
- ✅ AI price estimation in shopping list
- ✅ Server heartbeat + offline banner
- ✅ In-app bug reporter → automatic GitHub issue creation
- ✅ Cooking mode (start, steps, 3D wheel CSS)
- ✅ Kiosk ⚙️ Settings overlay button (replaces Android native button)
- ✅ Adaptive consumption anomaly detection
- ✅ CI/CD pipeline (PHP lint, JS lint, Docker build, Trivy security scan)
---
@@ -317,4 +420,13 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE
**Stimpfl Daniel** — [evershelfproject@gmail.com](mailto:evershelfproject@gmail.com)
- Website: [evershelfproject.dadaloop.it](https://evershelfproject.dadaloop.it/)
- GitHub: [@dadaloop82](https://github.com/dadaloop82)
---
## 📸 Screenshots
For a live walkthrough with real data and full AI enabled, visit the **[live demo](https://evershelfproject.dadaloop.it/demo)** — no installation required.
> Want to contribute a GIF or screenshots? See [CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome!
+48
View File
@@ -0,0 +1,48 @@
# Security Policy
## Supported Versions
Only the latest released version of EverShelf receives security fixes.
| Version | Supported |
|---------|-----------|
| Latest (1.7.x) | ✅ |
| Older releases | ❌ |
## Reporting a Vulnerability
**Please do NOT open a public GitHub issue for security vulnerabilities.**
Report security issues privately via email:
**📧 evershelfproject@gmail.com**
Include:
- A description of the vulnerability
- Steps to reproduce
- Potential impact
- Your GitHub username (optional — for credit)
I aim to acknowledge reports within **48 hours** and release a fix within **7 days** for critical issues.
## Scope
EverShelf is a **self-hosted** application. The security model assumes:
- It runs on a trusted private network (home LAN)
- Access from the internet requires the user to set up their own authentication layer (e.g. reverse proxy with Authelia, Nginx `auth_basic`)
Out-of-scope issues:
- Vulnerabilities that require physical access to the server
- Issues only affecting users who have not followed the security recommendations in the README
- Denial-of-service attacks on the demo server
## Security Features
- API keys stored server-side in `.env`, never sent to the browser
- `get_settings` returns only boolean flags (`gemini_key_set`), never raw key values
- Optional `SETTINGS_TOKEN` protects write operations (`hash_equals` to prevent timing attacks)
- `DEMO_MODE=true` blocks all write operations at the router level
- Parameterized SQL queries (PDO prepared statements) throughout
- Input validation and length limits on all user-supplied fields
- `.env` and `data/` directories denied via web server config (see README)
+44 -2
View File
@@ -39,8 +39,50 @@ try {
throw new RuntimeException('Cannot write cache file: ' . CACHE_FILE);
}
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . count($decoded['items'] ?? []) . " items cached\n";
$itemCount = count($decoded['items'] ?? []);
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n";
// ── Bring! server-side cleanup ────────────────────────────────────────
// After computing smart shopping, automatically remove stale Bring! items
// and add/update critical ones. This runs fully server-side every cron cycle.
try {
$cleanupResult = bringCleanupObsolete($db);
if (isset($cleanupResult['skipped'])) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! cleanup skipped: ' . $cleanupResult['skipped'] . "\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Bring! cleanup — removed: ' . ($cleanupResult['removed'] ?? 0)
. '/' . ($cleanupResult['candidates'] ?? 0) . ' candidates'
. ($cleanupResult['errors'] ? ', errors: ' . $cleanupResult['errors'] : '') . "\n";
}
$addResult = bringAutoAddCritical($db);
if (isset($addResult['skipped'])) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0)
. ', updated specs: ' . ($addResult['updated'] ?? 0) . "\n";
}
} catch (Throwable $be) {
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
}
// ── Shelf life pre-warming ────────────────────────────────────────────
// Pre-warm the opened shelf life cache for opened items not yet cached.
// Capped at 5 items per cron cycle to avoid Gemini rate limits.
try {
$prewarmResult = prewarmShelfLifeCache($db, 5);
if ($prewarmResult['warmed'] > 0) {
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm — warmed: ' . $prewarmResult['warmed']
. ', skipped: ' . $prewarmResult['skipped'] . "\n";
}
} catch (Throwable $pe) {
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm warning: ' . $pe->getMessage() . "\n";
}
} catch (Throwable $e) {
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $e->getMessage() . "\n";
$msg = $e->getMessage();
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
// Report to GitHub Issues (uses the same _phpErrorReport from index.php)
_phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e));
exit(1);
}
+151 -38
View File
@@ -9,13 +9,46 @@
define('DB_PATH', __DIR__ . '/../data/evershelf.db');
/**
* Ensure the data directory exists and is writable by the web-server user.
* This is needed when a Docker volume is first mounted: the image's chown
* step is applied to the image layer, but a fresh named volume starts empty
* (owned by root), making SQLite's PDO::__construct fail with HY000[14].
*/
function _ensureDataDir(): void {
$dir = dirname(DB_PATH);
if (!is_dir($dir)) {
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new \RuntimeException("Cannot create data directory: $dir");
}
}
if (!is_writable($dir)) {
// Try to fix permissions (only works when running as root, e.g. first boot)
@chmod($dir, 0775);
if (!is_writable($dir)) {
throw new \RuntimeException(
"Data directory is not writable: $dir — run: chown -R www-data:www-data $dir"
);
}
}
// Ensure backups sub-directory exists too
$backups = $dir . '/backups';
if (!is_dir($backups)) {
@mkdir($backups, 0775, true);
}
}
function getDB(): PDO {
_ensureDataDir();
$isNew = !file_exists(DB_PATH);
$db = new PDO('sqlite:' . DB_PATH);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec("PRAGMA journal_mode=WAL");
$db->exec("PRAGMA foreign_keys=ON");
$db->exec("PRAGMA synchronous=NORMAL"); // faster writes, still safe with WAL
$db->exec("PRAGMA cache_size=-8000"); // ~8 MB page cache (was 2 MB)
$db->exec("PRAGMA temp_store=MEMORY"); // temp tables in RAM
if ($isNew) {
initializeDB($db);
@@ -39,6 +72,7 @@ function initializeDB(PDO $db): void {
unit TEXT DEFAULT 'pz',
default_quantity REAL DEFAULT 1,
notes TEXT DEFAULT '',
shopping_name TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -61,6 +95,7 @@ function initializeDB(PDO $db): void {
quantity REAL NOT NULL,
location TEXT NOT NULL DEFAULT 'dispensa',
notes TEXT DEFAULT '',
undone INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
);
@@ -70,6 +105,11 @@ function initializeDB(PDO $db): void {
CREATE INDEX IF NOT EXISTS idx_inventory_location ON inventory(location);
CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id);
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at);
-- Composite indexes for hot queries
-- getStats(): WHERE type IN (...) AND created_at >= ...
CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at);
-- smartShopping(): GROUP BY product_id filtering on type+undone
CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone);
");
}
@@ -78,7 +118,12 @@ function migrateDB(PDO $db): void {
$cols = $db->query("PRAGMA table_info(products)")->fetchAll();
$colNames = array_column($cols, 'name');
if (!in_array('package_unit', $colNames)) {
$db->exec("ALTER TABLE products ADD COLUMN package_unit TEXT DEFAULT ''");
try { $db->exec("ALTER TABLE products ADD COLUMN package_unit TEXT DEFAULT ''"); }
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
}
if (!in_array('shopping_name', $colNames)) {
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
@@ -93,14 +138,19 @@ function migrateDB(PDO $db): void {
quantity REAL NOT NULL,
location TEXT NOT NULL DEFAULT 'dispensa',
notes TEXT DEFAULT '',
undone INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
)
");
$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("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_type_date ON transactions(type, created_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)");
}
// --- New shared tables ---
@@ -155,25 +205,53 @@ function migrateDB(PDO $db): void {
// Add opened_at column to inventory if missing
if (!in_array('opened_at', $invColNames)) {
$db->exec("ALTER TABLE inventory ADD COLUMN opened_at DATETIME DEFAULT NULL");
// Backfill: detect already-opened items and set opened_at + recalculate expiry
// Backfill: detect already-opened fridge items and set opened_at.
// Only frigo items — pantry/freezer fractional quantities don't imply opened.
backfillOpenedItems($db);
}
// Migration: undo incorrect backfill for non-frigo items.
// The original backfill also tagged dispensa/freezer items as opened, which overwrote
// their manufacturer expiry_date with a short estimated value. Clear opened_at so they
// return to the sealed section; clear expiry_date so users can re-enter the real date.
$migDone = $db->query("SELECT value FROM app_settings WHERE key = 'migration_fix_nonfrigo_opened_v1'")->fetchColumn();
if (!$migDone) {
$db->exec("UPDATE inventory SET opened_at = NULL, expiry_date = NULL
WHERE location NOT IN ('frigo') AND opened_at IS NOT NULL");
$db->exec("INSERT OR REPLACE INTO app_settings (key, value)
VALUES ('migration_fix_nonfrigo_opened_v1', '1')");
}
// Migration v2: recalculate sealed fridge item expiry (fridge extends shelf life)
$migrated = $db->query("SELECT value FROM app_settings WHERE key = 'migration_fridge_expiry_v1'")->fetchColumn();
if (!$migrated) {
recalcSealedFridgeExpiry($db);
$db->exec("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('migration_fridge_expiry_v1', '1')");
}
// Add undone column to transactions if missing
$txCols = $db->query("PRAGMA table_info(transactions)")->fetchAll();
$txColNames = array_column($txCols, 'name');
if (!in_array('undone', $txColNames)) {
$db->exec("ALTER TABLE transactions ADD COLUMN undone INTEGER DEFAULT 0");
}
// Ensure composite indexes exist (added in v1.7.5 for performance)
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)");
}
/**
* Backfill opened_at for existing inventory items that appear to be opened.
* Backfill opened_at for frigo items that appear to be opened.
* An item is considered opened if:
* - conf unit with fractional quantity
* - weight/volume unit (g,kg,ml,l) with quantity < default_quantity
* Uses updated_at as the approximate opened_at date.
* Recalculates expiry_date based on opened shelf life from opened_at.
* Does NOT overwrite expiry_date — the manufacturer date is preserved;
* getStats computes opened expiry on-the-fly from opened_at.
*
* Only frigo items: pantry/freezer fractional quantities are normal
* (e.g. 3 of 6 UHT milks) and do not indicate a food-safety expiry change.
*/
function backfillOpenedItems(PDO $db): void {
$stmt = $db->query("
@@ -181,7 +259,7 @@ function backfillOpenedItems(PDO $db): void {
p.name, p.category, p.unit, p.default_quantity
FROM inventory i
JOIN products p ON i.product_id = p.id
WHERE i.quantity > 0
WHERE i.quantity > 0 AND i.location = 'frigo'
");
$rows = $stmt->fetchAll();
@@ -200,15 +278,9 @@ function backfillOpenedItems(PDO $db): void {
if (!$isOpened) continue;
$openedAt = $row['updated_at'];
$openedDays = estimateOpenedExpiryDaysPHP($row['name'], $row['category'], $row['location']);
if ($row['vacuum_sealed']) $openedDays = (int)round($openedDays * 1.5);
// Calculate new expiry from opened_at
$newExpiry = date('Y-m-d', strtotime($openedAt . " +{$openedDays} days"));
$upd = $db->prepare("UPDATE inventory SET opened_at = ?, expiry_date = ? WHERE id = ?");
$upd->execute([$openedAt, $newExpiry, $row['id']]);
// Only set opened_at — do NOT touch expiry_date (manufacturer date is preserved)
$upd = $db->prepare("UPDATE inventory SET opened_at = ? WHERE id = ? AND opened_at IS NULL");
$upd->execute([$row['updated_at'], $row['id']]);
}
}
@@ -245,8 +317,37 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(lenticchie|ceci|fagioli|piselli)\b/', $n) && !preg_match('/\b(cotto|vapore|scatola)\b/', $n)) return 365;
}
// ── D: Freezer ───────────────────────────────────────────────────────
if ($loc === 'freezer') return 90;
// ── D: Freezer — per-product estimates (USDA/EFSA guidelines) ───────
if ($loc === 'freezer') {
// Bread, pastry, dough
if (preg_match('/\b(pane|bread|toast|brioche|ciabatta|baguette|focaccia|pizza\s*base|impasto)\b/', $n)) return 90;
if (preg_match('/\b(pasta\s+fresca|gnocchi|ravioli|tortellini|lasagna\s+fresca)\b/', $n)) return 60;
if (preg_match('/\b(croissant|cornetto|pasticceria|dolce|torta|plumcake|muffin|biscotti)\b/', $n)) return 90;
// Ice cream / sorbet
if (preg_match('/\b(gelato|sorbetto|ice\s*cream|ghiacciolo)\b/', $n)) return 365;
// Fish & seafood — shorter (36 months)
if (preg_match('/\b(salmone|trota|spigola|orata|tonno|merluzzo|baccalà|nasello|sgombro|pesce|calamaro|gambero|gamberetti|polpo|seppia|cozza|vongola|frutti\s+di\s+mare|seafood)\b/', $n)) return 120;
// Poultry — 9 months
if (preg_match('/\b(pollo|tacchino|anatra|faraona|petto\s+di\s+pollo|coscia|fesa)\b/', $n)) return 270;
// Red meat whole cuts — 12 months
if (preg_match('/\b(manzo|vitello|agnello|maiale|lonza|costata|arrosto|fesa|fettina|bistecca)\b/', $n)) return 365;
// Ground meat / mince — 34 months
if (preg_match('/\b(macinato|macinata|hamburger|polpette|ragù)\b/', $n)) return 120;
// Sausage / cured meat frozen
if (preg_match('/\b(salsiccia|würstel|wurstel|salame|pancetta|speck|prosciutto)\b/', $n)) return 60;
// Dairy
if (preg_match('/\b(burro)\b/', $n)) return 270;
if (preg_match('/\b(panna)\b/', $n)) return 90;
if (preg_match('/\b(formaggio|mozzarella|ricotta)\b/', $n)) return 90;
// Vegetables (blanched/processed for freezer)
if (preg_match('/\b(piselli|fagioli|fagiolini|spinaci|broccoli|cavolfiore|carote|mais|edamame|verdure\s+miste|minestrone)\b/', $n)) return 270;
// Fruits
if (preg_match('/\b(fragole|lamponi|mirtilli|more|ciliegia|frutta\s+mista|frutta)\b/', $n)) return 270;
// Stocks, soups, sauces (already cooked)
if (preg_match('/\b(brodo|zuppa|minestra|sugo|salsa|passata)\b/', $n)) return 180;
// Generic freezer fallback
return 180;
}
// ── E: Pantry/dispensa — specific products then generic fallback ─────
if ($loc !== 'frigo') {
@@ -257,14 +358,25 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\bpane\b/', $n)) return 4;
// Specific jarred tomato sauce in pantry (opened, not refrigerated)
if (preg_match('/salsa\s+di\s+(pomodoro|pronta)/', $n)) return 5;
return 60; // generic pantry fallback (was 30, doubled)
// Dairy opened outside fridge: bad very quickly at room temperature
if (preg_match('/\bpanna\b/', $n)) return 3;
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 2;
if (preg_match('/\blatte\b/', $n)) return 1;
if (preg_match('/\bformaggio\b/', $n)) return 2;
// Root vegetables / tubers in pantry: sfusi in un sacchetto, durano 3-5 settimane
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 30;
if (preg_match('/\b(cipolla|cipolle|aglio|scalogno|porro)\b/', $n)) return 30;
if (preg_match('/\b(carota|carote)\b/', $n)) return 14;
return 60; // generic pantry fallback
}
// ── F: Fridge — short-life perishables ──────────────────────────────
if (preg_match('/latte\s+(fresco|intero|parzial|scremato)/', $n)) return 3;
if (preg_match('/latte\s+(uht|a\s+lunga)/', $n)) return 5;
if (preg_match('/\blatte\b/', $n)) return 4;
if (preg_match('/\byogurt\b/', $n)) return 5;
if (preg_match('/latte\s+(uht|a\s+lunga)/', $n)) return 7;
// Long-life mountain/brand milks stored in pantry before use (UHT)
if (preg_match('/latte.*(montagna|alta\s+qual|parmalat|granarolo|esselunga|conservaz|microfiltrat)/i', $n)) return 7;
if (preg_match('/\blatte\b/', $n)) return 7; // generic: default to UHT (most common in IT households)
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 5;
if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 3;
if (preg_match('/philadelphia|spalmabile/', $n)) return 7;
if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5;
@@ -277,25 +389,26 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(pollo|tacchino|maiale|manzo|vitello|agnello)\b/', $n)) return 2;
if (preg_match('/salmone|tonno\s+fresco|pesce(?!\s+in)/', $n)) return 2;
if (preg_match('/\b(passata|pelati|polpa|sugo|salsa\s+di\s+pomodoro)\b/', $n)) return 5;
if (preg_match('/insalata|rucola|spinaci|lattuga|crescione|germogli/', $n)) return 2;
if (preg_match('/insalata|rucola|spinaci|lattuga|crescione|germogli/', $n)) return 4;
if (preg_match('/\b(succo|spremuta)\b/', $n)) return 3;
if (preg_match('/\b(birra|beer)\b/', $n)) return 3;
if (preg_match('/\bvino\b/', $n)) return 5;
if (preg_match('/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/', $n)) return 4;
// Fruit opened/cut in fridge — much shorter than sealed
if (preg_match('/\bavocado\b/', $n)) return 2;
if (preg_match('/\b(banana|banane|fragola|lampone|pesca|albicocca|ciliegia|mango|papaya)\b/', $n)) return 2;
if (preg_match('/\b(mela|pera|nettarina|prugna|kiwi|ananas|uva|melone|anguria)\b/', $n)) return 3;
if (preg_match('/\b(arancia|mandarino|pompelmo|clementina|limone)\b/', $n)) return 3; // cut citrus
// Vegetables opened/cut in fridge
if (preg_match('/\b(zucchina|zucchine|melanzana|pomodor)\b/', $n)) return 3;
if (preg_match('/\b(peperone|peperoni)\b/', $n)) return 3;
if (preg_match('/\b(broccolo|broccoli|cavolfiore|cavolo)\b/', $n)) return 3;
if (preg_match('/\bsedano\b|\bfinocchio\b/', $n)) return 3;
if (preg_match('/\b(cipolla|cipolle|cipollotto|scalogno|porro)\b/', $n)) return 4;
if (preg_match('/\b(carota|carote)\b/', $n)) return 5;
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 3; // cooked/cut potato
if (preg_match('/\baglio\b/', $n)) return 10;
// Fruit in fridge (opened pack, not necessarily cut)
if (preg_match('/\bavocado\b/', $n)) return 3;
if (preg_match('/\b(fragola|fragole|lampone|lamponi|mirtillo|mirtilli|mora|more)\b/', $n)) return 4;
if (preg_match('/\b(banana|banane|pesca|pesche|albicocca|albicocche|ciliegia|ciliegie|mango|papaya)\b/', $n)) return 4;
if (preg_match('/\b(mela|mele|pera|pere|nettarina|prugna|kiwi|ananas|uva|melone|anguria)\b/', $n)) return 5;
if (preg_match('/\b(arancia|arance|mandarino|mandarini|pompelmo|clementina|limone|limoni)\b/', $n)) return 7;
// Vegetables in fridge (opened pack)
if (preg_match('/\b(zucchina|zucchine|melanzana|melanzane|pomodor)\b/', $n)) return 5;
if (preg_match('/\b(peperone|peperoni)\b/', $n)) return 5;
if (preg_match('/\b(broccolo|broccoli|cavolfiore|cavolo)\b/', $n)) return 4;
if (preg_match('/\bsedano\b|\bfinocchio\b/', $n)) return 5;
if (preg_match('/\b(cipolla|cipolle|cipollotto|scalogno|porro)\b/', $n)) return 6;
if (preg_match('/\b(carota|carote)\b/', $n)) return 7;
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4;
if (preg_match('/\baglio\b/', $n)) return 14;
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
@@ -303,7 +416,7 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(senape|mustard)\b/', $n)) return 90;
if (preg_match('/salsa\s+di\s+soia|soy\s*sauce/', $n)) return 90;
if (preg_match('/\b(tabasco|worcestershire|sriracha)\b/', $n)) return 180;
if (preg_match('/confettura|marmellata/', $n)) return 60;
if (preg_match('/confettura|marmellata/', $n)) return 180;
if (preg_match('/nutella|cioccolat/', $n)) return 60;
// ── H: Category fallbacks ────────────────────────────────────────────
@@ -361,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('/broccoli|cavolfiore|cavolo|spinaci|bietola/', $n)) $days = 5;
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('/nutella|marmellata|miele/', $n)) $days = 365;
elseif (preg_match('/passata|pelati|pomodor/', $n)) $days = 730;
+5327 -963
View File
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
<?php
/**
* EverShelf Scale Gateway — Auto-discovery
*
* Scans the server's local /24 subnet for any host responding on the gateway
* port (default 8765) and confirms it with a WebSocket handshake.
*
* Returns: {"found": ["ws://192.168.1.100:8765", ...]}
*/
header('Content-Type: application/json');
header('Cache-Control: no-cache');
$port = (int)($_GET['port'] ?? 8765);
if ($port < 1 || $port > 65535) $port = 8765;
// ── Determine server LAN IP ────────────────────────────────────────────────
// SERVER_ADDR may be 127.0.0.1 when accessed via internal vhost — fall back
// to a UDP trick (no actual packet sent) to find the default-route interface IP.
function localLanIp(): string {
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if ($sock) {
@socket_connect($sock, '8.8.8.8', 53);
@socket_getsockname($sock, $ip);
socket_close($sock);
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
}
// Fallback: parse /proc/net/route for default gateway interface then ip neigh
$ifaces = @net_get_interfaces();
if ($ifaces) {
foreach ($ifaces as $name => $info) {
if ($name === 'lo') continue;
foreach ($info['unicast'] ?? [] as $u) {
$ip = $u['address'] ?? '';
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE)) continue;
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
}
}
}
return '';
}
$serverIp = localLanIp();
$parts = explode('.', $serverIp);
if (count($parts) !== 4) {
echo json_encode(['error' => 'Cannot determine local subnet', 'server_ip' => $serverIp]);
exit;
}
$subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.';
// ── Phase 1: Async TCP connect to all 254 hosts ────────────────────────────
// Non-blocking stream_socket_client + stream_select to detect open ports quickly.
// Total scan budget: 1.5 seconds.
$candidates = [];
for ($i = 1; $i <= 254; $i++) {
$ip = $subnet . $i;
$sock = @stream_socket_client(
"tcp://{$ip}:{$port}", $errno, $errstr, 0,
STREAM_CLIENT_ASYNC_CONNECT | STREAM_CLIENT_CONNECT
);
if ($sock !== false) {
stream_set_blocking($sock, false);
$candidates[$ip] = $sock;
}
}
$found_tcp = [];
$deadline = microtime(true) + 1.5;
while (!empty($candidates) && microtime(true) < $deadline) {
$write = array_values($candidates);
$except = array_values($candidates);
$read = null;
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
$n = @stream_select($read, $write, $except, 0, $usec);
if ($n === false || $n === 0) break;
// Sockets in $except = connection refused/error
$failed = [];
foreach ($except as $s) {
$ip = array_search($s, $candidates, true);
if ($ip !== false) $failed[$ip] = true;
}
// Sockets in $write = connection complete (may overlap with $except on error)
foreach ($write as $s) {
$ip = array_search($s, $candidates, true);
if ($ip === false) continue;
if (!isset($failed[$ip])) {
$found_tcp[] = $ip;
}
@fclose($s);
unset($candidates[$ip]);
}
// Close failed sockets too
foreach ($failed as $ip => $_) {
if (isset($candidates[$ip])) {
@fclose($candidates[$ip]);
unset($candidates[$ip]);
}
}
}
foreach ($candidates as $s) @fclose($s); // close remaining (timeout)
// ── Phase 2: WebSocket handshake to confirm each TCP responder ─────────────
$gateways = [];
foreach ($found_tcp as $ip) {
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
if (!$sock) continue;
stream_set_timeout($sock, 2);
$key = base64_encode(random_bytes(16));
fwrite($sock,
"GET / HTTP/1.1\r\n" .
"Host: {$ip}:{$port}\r\n" .
"Upgrade: websocket\r\n" .
"Connection: Upgrade\r\n" .
"Sec-WebSocket-Key: {$key}\r\n" .
"Sec-WebSocket-Version: 13\r\n" .
"\r\n"
);
$resp = '';
$dl = microtime(true) + 2;
while (microtime(true) < $dl && !feof($sock)) {
$line = fgets($sock, 256);
if ($line === false) break;
$resp .= $line;
if ($line === "\r\n") break;
}
fclose($sock);
if (str_contains($resp, '101')) {
$gateways[] = "ws://{$ip}:{$port}";
}
}
echo json_encode([
'found' => $gateways,
'subnet' => rtrim($subnet, '.') . '.0/24',
'server_ip' => $serverIp,
]);
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* EverShelf Scale Gateway — Connection ping / test
*
* Performs a WebSocket handshake with the gateway and returns
* {"ok":true} on success, {"ok":false,"error":"..."} on failure.
*
* Usage: GET /api/scale_ping.php?url=ws%3A%2F%2F192.168.1.100%3A8765
*/
header('Content-Type: application/json');
header('Cache-Control: no-cache');
$rawUrl = $_GET['url'] ?? '';
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
echo json_encode(['ok' => false, 'error' => 'Invalid gateway URL (must start with ws://)']);
exit;
}
$parsed = parse_url($rawUrl);
$host = $parsed['host'] ?? '';
$port = (int)($parsed['port'] ?? 8765);
$path = ($parsed['path'] ?? '') ?: '/';
if (!$host || $port < 1 || $port > 65535) {
echo json_encode(['ok' => false, 'error' => 'Invalid host or port']);
exit;
}
// Try to open a TCP connection with a 5-second timeout
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
if (!$sock) {
echo json_encode(['ok' => false, 'error' => "Cannot connect to {$host}:{$port}{$errstr}"]);
exit;
}
stream_set_timeout($sock, 5);
// Perform WebSocket handshake
$wsKey = base64_encode(random_bytes(16));
fwrite($sock,
"GET {$path} HTTP/1.1\r\n" .
"Host: {$host}:{$port}\r\n" .
"Upgrade: websocket\r\n" .
"Connection: Upgrade\r\n" .
"Sec-WebSocket-Key: {$wsKey}\r\n" .
"Sec-WebSocket-Version: 13\r\n" .
"\r\n"
);
// Read HTTP response (looking for 101 Switching Protocols)
$resp = '';
while (!feof($sock)) {
$line = fgets($sock, 1024);
if ($line === false) break;
$resp .= $line;
if ($line === "\r\n") break;
}
fclose($sock);
if (str_contains($resp, '101')) {
echo json_encode(['ok' => true]);
} else {
echo json_encode(['ok' => false, 'error' => 'WebSocket handshake failed — check that the gateway app is running']);
}
+229
View File
@@ -0,0 +1,229 @@
<?php
/**
* EverShelf Scale Gateway — SSE Relay
*
* Bridges the Android BLE gateway (ws://) to Server-Sent Events (SSE) so that
* browsers on HTTPS pages can receive weight data without mixed-content errors.
*
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765
*/
// ── Input validation ──────────────────────────────────────────────────────────
$rawUrl = $_GET['url'] ?? '';
// Only accept ws:// scheme with valid host and optional port/path
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
header('Content-Type: text/event-stream');
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Invalid gateway URL (must start with ws://)']) . "\n\n";
exit;
}
$parsed = parse_url($rawUrl);
$wsHost = $parsed['host'] ?? '';
$wsPort = (int)($parsed['port'] ?? 8765);
$wsPath = ($parsed['path'] ?? '') ?: '/';
if (!$wsHost || $wsPort < 1 || $wsPort > 65535) {
header('Content-Type: text/event-stream');
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Invalid host or port']) . "\n\n";
exit;
}
// ── SSE headers ───────────────────────────────────────────────────────────────
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('X-Accel-Buffering: no'); // Disable nginx / Caddy buffering
header('Content-Encoding: identity'); // Prevent gzip/deflate compression
set_time_limit(0);
ignore_user_abort(false); // stop when browser closes connection
// Clear all PHP output-buffering levels so echo/flush goes straight to SAPI
while (ob_get_level() > 0) {
ob_end_clean();
}
// ── Connect to Android gateway ────────────────────────────────────────────────
$sock = @stream_socket_client("tcp://{$wsHost}:{$wsPort}", $errno, $errstr, 5);
if (!$sock) {
echo 'data: ' . json_encode([
'type' => 'error',
'message' => "Cannot connect to gateway ({$wsHost}:{$wsPort}): {$errstr}",
]) . "\n\n";
flush();
exit;
}
// ── WebSocket handshake (PHP acts as client) ──────────────────────────────────
// RFC 6455: client frames MUST be masked.
$wsKey = base64_encode(random_bytes(16));
stream_set_blocking($sock, true);
stream_set_timeout($sock, 5);
fwrite($sock,
"GET {$wsPath} HTTP/1.1\r\n" .
"Host: {$wsHost}:{$wsPort}\r\n" .
"Upgrade: websocket\r\n" .
"Connection: Upgrade\r\n" .
"Sec-WebSocket-Key: {$wsKey}\r\n" .
"Sec-WebSocket-Version: 13\r\n" .
"\r\n"
);
// Read HTTP 101 Switching Protocols response
$httpResp = '';
$deadline = microtime(true) + 5;
while (microtime(true) < $deadline && !feof($sock)) {
$line = fgets($sock, 1024);
if ($line === false) break;
$httpResp .= $line;
if ($line === "\r\n") break;
}
if (!str_contains($httpResp, '101')) {
fclose($sock);
echo 'data: ' . json_encode([
'type' => 'error',
'message' => 'WebSocket handshake failed — is the gateway URL correct?',
]) . "\n\n";
flush();
exit;
}
// Switch to non-blocking for poll-based relay loop
stream_set_blocking($sock, false);
stream_set_timeout($sock, 0);
// Ask gateway for current status immediately
wsSend($sock, json_encode(['type' => 'get_status']));
// ── SSE relay loop ────────────────────────────────────────────────────────────
$buf = '';
$lastPing = time();
while (!connection_aborted()) {
$chunk = @fread($sock, 8192);
if ($chunk === false || (feof($sock) && $chunk === '')) {
// Gateway closed the connection
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Gateway disconnected']) . "\n\n";
flush();
break;
}
if ($chunk !== '') {
$buf .= $chunk;
while (($payload = wsRead($buf, $sock)) !== null) {
if ($payload === false) goto done; // close frame received
if ($payload === '') continue; // ping/pong control frames (handled inside wsRead)
echo "data: {$payload}\n\n";
flush();
}
}
// SSE keep-alive comment + gateway WebSocket ping every 20 seconds
if (time() - $lastPing >= 20) {
echo ": keep-alive\n\n";
flush();
$lastPing = time();
wsPing($sock);
}
usleep(100_000); // 100 ms polling interval
}
done:
fclose($sock);
// ── WebSocket helpers ─────────────────────────────────────────────────────────
/** Send a masked text frame from PHP (client) to the gateway (server). */
function wsSend($sock, string $payload): void
{
$len = strlen($payload);
$mask = random_bytes(4);
if ($len < 126) {
$header = "\x81" . chr(0x80 | $len);
} elseif ($len < 65536) {
$header = "\x81\xFE" . pack('n', $len); // opcode=text, mask bit, 2-byte length
} else {
$header = "\x81\xFF" . pack('J', $len); // opcode=text, mask bit, 8-byte length
}
$masked = '';
for ($i = 0; $i < $len; $i++) {
$masked .= $payload[$i] ^ $mask[$i % 4];
}
@fwrite($sock, $header . $mask . $masked);
}
/** Send a masked ping to keep the gateway connection alive. */
function wsPing($sock): void
{
@fwrite($sock, "\x89\x80" . random_bytes(4)); // FIN+ping, MASK bit, 4-byte mask, no payload
}
/**
* Try to decode one complete WebSocket frame from the read buffer.
* The gateway (server) sends unmasked frames to PHP (client).
*
* Returns:
* string — decoded text/binary payload
* '' — control frame (ping/pong) handled internally, skip
* false — close frame received
* null — not enough data yet, read more
*/
function wsRead(string &$buf, $sock): string|null|false
{
if (strlen($buf) < 2) return null;
$b0 = ord($buf[0]);
$b1 = ord($buf[1]);
$op = $b0 & 0x0F;
$msk = ($b1 & 0x80) !== 0; // servers never mask, but handle defensively
$len = $b1 & 0x7F;
$off = 2;
if ($len === 126) {
if (strlen($buf) < 4) return null;
$len = unpack('n', substr($buf, 2, 2))[1];
$off = 4;
} elseif ($len === 127) {
if (strlen($buf) < 10) return null;
$len = unpack('J', substr($buf, 2, 8))[1];
$off = 10;
}
if ($msk) $off += 4;
if (strlen($buf) < $off + $len) return null;
$maskKey = $msk ? substr($buf, $off - 4, 4) : null;
$payload = substr($buf, $off, $len);
if ($msk && $maskKey) {
for ($i = 0; $i < strlen($payload); $i++) {
$payload[$i] = $payload[$i] ^ $maskKey[$i % 4];
}
}
// Consume frame from buffer
$buf = substr($buf, $off + $len);
if ($op === 0x8) return false; // close frame
if ($op === 0x9) { // ping → send masked pong
$pLen = strlen($payload);
$mask = random_bytes(4);
$maskedPayload = '';
for ($i = 0; $i < $pLen; $i++) {
$maskedPayload .= $payload[$i] ^ $mask[$i % 4];
}
@fwrite($sock, "\x8A" . chr(0x80 | $pLen) . $mask . $maskedPayload);
return '';
}
if ($op === 0xA) return ''; // pong — ignore
return $payload; // 0x1 = text, 0x2 = binary
}
+2103 -455
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+6923 -1495
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{}
+170
View File
@@ -0,0 +1,170 @@
{
"226887def70e33ef73290ebfe75ed4d0": {
"days": 7,
"source": "ai",
"name": "Polpa di pomodoro finissima",
"location": "frigo",
"ts": 1777444819
},
"0ed51c9496aa9edfe38caf41772f54ed": {
"days": 7,
"source": "rule",
"name": "Latte di Montagna",
"location": "frigo",
"ts": 1777444820
},
"2d63d0216a75d46b465150e925d2e7ad": {
"days": 30,
"source": "rule",
"name": "Burro",
"location": "frigo",
"ts": 1777444821
},
"9afdf35c4a256867ef47c32495349eb6": {
"days": 5,
"source": "rule",
"name": "Yaourt Vanille",
"location": "frigo",
"ts": 1777480477
},
"584f57418733a1f2acd29fe2e8816129": {
"days": 5,
"source": "rule",
"name": "Passata di pomodoro",
"location": "frigo",
"ts": 1778133522
},
"baeb7f2021b4bb91c368c9131a61f07c": {
"days": 10,
"source": "rule",
"name": "Formaggio Monte Maria",
"location": "frigo",
"ts": 1778133523
},
"063f2d534407214786d039bb2bffbb93": {
"days": 5,
"source": "rule",
"name": "Carote",
"location": "frigo",
"ts": 1778133524
},
"10a3d07c19bb1f889ebc9293862b4b36": {
"days": 60,
"source": "rule",
"name": "Ovomaltine",
"location": "dispensa",
"ts": 1778419084
},
"0fbad7ccd8b6155c06aaa6b3c17a67d3": {
"days": 365,
"source": "rule",
"name": "Linguine pasta di Gragnano Igp",
"location": "dispensa",
"ts": 1778419084
},
"b4a03e7356e7a0983b9c8af5f3cd8c57": {
"days": 60,
"source": "rule",
"name": "Polpa di pomodoro finissima",
"location": "dispensa",
"ts": 1778419085
},
"b8334ff0febd5c0440c9b24c9f3132ed": {
"days": 180,
"source": "rule",
"name": "Basilico tritato surgelato",
"location": "freezer",
"ts": 1778419086
},
"0cb14384d0ba763ccf12e079d6aa8d34": {
"days": 60,
"source": "rule",
"name": "Salsa Pronta Ciliegini",
"location": "dispensa",
"ts": 1778419086
},
"188634f49edb8b014a46942ee9fad689": {
"days": 180,
"source": "rule",
"name": "Farina Barilla",
"location": "dispensa",
"ts": 1778419204
},
"c8db359d8709c69a95f0e6f68216d220": {
"days": 9999,
"source": "rule",
"name": "Bicarbonato",
"location": "dispensa",
"ts": 1778419205
},
"a6d16a09fd9a6bfbd0a915f05dd71780": {
"days": 7,
"source": "ai",
"name": "Salsa Pronta Ciliegini",
"location": "frigo",
"ts": 1778419205
},
"4f8f1bb04a00e5fc62d7a9cfb21e1796": {
"days": 365,
"source": "rule",
"name": "Riso Chicchi Ricchi Gran Risparmio",
"location": "dispensa",
"ts": 1778419206
},
"e116e4c11084a463f9aaac02e1749fe7": {
"days": 90,
"source": "rule",
"name": "Salsa di soia",
"location": "dispensa",
"ts": 1778419207
},
"b1ad9afd4139b3f225b79af4dae256ce": {
"days": 60,
"source": "rule",
"name": "Tè Al limone",
"location": "dispensa",
"ts": 1778419504
},
"7ff2b7d326dcba52a664cebbf12f78a2": {
"days": 3,
"source": "ai",
"name": "Piselli fini 1\/2 vapore",
"location": "frigo",
"ts": 1778419505
},
"71062dc7ffd82b3ee4f40bad076a7c91": {
"days": 60,
"source": "rule",
"name": "Cioccolato bianco",
"location": "frigo",
"ts": 1778419506
},
"38a0eaea422dfe970eba125494e75981": {
"days": 180,
"source": "rule",
"name": "Zucca a pezzi",
"location": "freezer",
"ts": 1778419506
},
"cde21270e1cd50c431742e49117b225d": {
"days": 7,
"source": "rule",
"name": "Pancetta Dolce",
"location": "frigo",
"ts": 1778419507
},
"9e4189bd3f8cb1121e7389967dd4f74c": {
"days": 180,
"source": "rule",
"name": "Farina di grano tenero tipo rossa",
"location": "dispensa",
"ts": 1778427005
},
"e3472dd051ed13ae18fc96bbebedc1ba": {
"days": 60,
"source": "rule",
"name": "Lievito di birra",
"location": "dispensa",
"ts": 1778427005
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"dc1bb00e006a5ed073aad9b0ca2f1601": "Toast",
"f03b656f4cfaa9d633fc155cdafcb83b": "Sale",
"fa1266e5e6bb32602e08aaf9434ec9ad": "Patate",
"ca2da3ad2a7b42e717f766e06a83730e": "Verdure",
"ce8f4f54fc6ead0f0a8ce36503bba462": "Pasta",
"2ddb0faf33c4ceeed89fada2c7c2b9c5": "Ingredienti Spezie",
"0290647fcd95ec97f0d6666c46a72943": "Brodo",
"405ea6ec33d54042d046599650f422ea": "Succo",
"f624c420f14d8eff122c0bb395eb63da": "Snack Dolci",
"92751fbb97923590c402bc7810778b36": "Biscotti",
"8727f7abcb66764b5eb3d1f036bc18b8": "Tè",
"0eb53fe1a5d4d106eac47c8a81d1afe7": "Farina",
"0ebada5597d1d166d0ed8f49500bfeba": "Verdure",
"fe7456efb7e767a06e3af9f5ec7b3637": "Piatti Pronti",
"2a5d2289bb7bc306dd066dfaff7ef581": "Ingredienti Spezie",
"b630c06f2ac72a1e2ffbd57d327a3733": "Salsa",
"32a05ae91ccfa4d37be454836971436b": "Ingredienti",
"a21f0e7718c8f12166d864d0d05f60a0": "Salsa"
}
+1
View File
@@ -0,0 +1 @@
{}
-35
View File
@@ -500,39 +500,6 @@ paths:
"200":
description: Recipe deleted
/index.php?action=dupliclick_login:
post:
summary: Login to DupliClick (online shopping)
tags: [DupliClick]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
email:
type: string
password:
type: string
responses:
"200":
description: Login successful
/index.php?action=dupliclick_search:
get:
summary: Search DupliClick product catalog
tags: [DupliClick]
parameters:
- name: q
in: query
required: true
schema:
type: string
responses:
"200":
description: Search results
/index.php?action=tts_proxy:
post:
summary: Proxy TTS request to external endpoint
@@ -685,7 +652,5 @@ tags:
description: Recipe storage
- name: Settings
description: Application and server settings
- name: DupliClick
description: DupliClick online shopping integration
- name: TTS
description: Text-to-Speech proxy
+332
View File
@@ -0,0 +1,332 @@
# 🔌 API Reference
EverShelf exposes a single PHP endpoint: **`api/index.php`**. All actions are selected via the `action` query parameter.
> **Full OpenAPI 3.1 spec:** [`docs/openapi.yaml`](https://github.com/dadaloop82/EverShelf/blob/main/docs/openapi.yaml)
---
## Base URL
```
https://your-server/api/index.php?action=ACTION_NAME
```
GET requests pass parameters as query params; POST requests send JSON in the body.
---
## Rate Limits
| Tier | Limit | Applies to |
|------|-------|-----------|
| Standard | 120 req/min | All general endpoints |
| AI | 15 req/min | `gemini_*`, `generate_recipe*` |
| Strict | 5 req/min | `report_error` |
Exceeded limits return HTTP 429 with `{"error": "rate_limit_exceeded"}`.
---
## Products
### `search_barcode` — GET
Search for a product in the local database by barcode.
| Param | Type | Description |
|-------|------|-------------|
| `barcode` | string | EAN/UPC barcode |
### `lookup_barcode` — GET
Look up a barcode on Open Food Facts (external call).
| Param | Type | Description |
|-------|------|-------------|
| `barcode` | string | EAN/UPC barcode |
### `product_save` — POST
Create or update a product. Pass `id` to update.
```json
{
"id": 42,
"name": "Pasta Barilla",
"brand": "Barilla",
"category": "pasta",
"unit": "g",
"default_quantity": 500,
"barcode": "8076800105988"
}
```
### `product_get` — GET
Get product details by `id`.
### `product_delete` — POST
Delete a product by `id`.
### `products_list` — GET
List all products.
### `products_search` — GET
Search products by name (`?q=pasta`).
---
## Inventory
### `inventory_list` — GET
List all inventory items with product details, grouped.
**Response:**
```json
{
"inventory": [
{
"id": 1,
"product_id": 42,
"name": "Pasta Barilla",
"quantity": 2,
"unit": "pz",
"location": "dispensa",
"expiry_date": "2027-03-01",
"opened_at": null,
"vacuum_sealed": 0
}
]
}
```
### `inventory_add` — POST
Add a product to inventory.
```json
{
"product_id": 42,
"quantity": 3,
"location": "dispensa",
"expiry_date": "2027-03-01",
"vacuum_sealed": false
}
```
**Locations:** `dispensa`, `frigo`, `freezer`, `altro`
### `inventory_use` — POST
Consume inventory. Set `use_all: true` to consume all stock at a location.
```json
{
"product_id": 42,
"quantity": 1,
"location": "dispensa"
}
```
```json
{
"product_id": 42,
"use_all": true,
"location": "__all__",
"notes": "Buttato"
}
```
### `inventory_update` — POST
Update an inventory entry by `id`.
### `inventory_delete` — POST
Remove an inventory entry by `id`.
### `inventory_summary` — GET
Returns item counts per location.
```json
{
"dispensa": 12,
"frigo": 5,
"freezer": 8
}
```
---
## Transactions (Log)
### `transactions_list` — GET
Returns the operation log.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `limit` | int | 50 | Results per page |
| `offset` | int | 0 | Pagination offset |
### `transaction_undo` — POST
Undo a transaction within 24 hours.
```json
{ "id": 873 }
```
**Response on success:**
```json
{ "success": true, "name": "Tonno all'olio d'oliva" }
```
**Error cases:**
```json
{ "error": "...", "already_undone": true }
{ "error": "...", "too_old": true }
```
### `stats` — GET
Returns waste and consumption statistics for the last 30 days.
---
## AI / Gemini
All AI endpoints require `GEMINI_API_KEY` to be configured. Rate limit: 15 req/min.
### `gemini_expiry` — POST
Read an expiry date from a product photo.
```json
{ "image": "data:image/jpeg;base64,..." }
```
### `gemini_identify` — POST
Identify a product from a photo.
```json
{ "image": "data:image/jpeg;base64,..." }
```
### `gemini_chat` — POST
Chat with the AI kitchen assistant.
```json
{ "message": "Cosa posso fare con la pasta?", "history": [] }
```
### `generate_recipe` — POST
Generate a recipe based on current inventory.
```json
{ "persons": 2, "meal": "dinner", "preferences": {} }
```
### `generate_recipe_stream` — POST
Same as `generate_recipe` but streams output via Server-Sent Events.
### `gemini_product_hint` — POST
Get AI storage location + shelf-life hint for a new product.
### `gemini_shopping_enrich` — POST
Enrich shopping suggestions with practical tips.
### `gemini_anomaly_explain` — POST
Get a plain-language explanation for a specific inventory anomaly.
---
## Shopping List (Bring!)
Requires `BRING_EMAIL` and `BRING_PASSWORD` in `.env`.
### `bring_list` — GET
Get the current Bring! shopping list.
### `bring_add` — POST
Add items to the Bring! list.
```json
{ "items": ["Latte", "Pane"] }
```
### `bring_remove` — POST
Remove an item from the Bring! list.
```json
{ "name": "Latte" }
```
### `smart_shopping` — GET
Get smart shopping predictions based on consumption history.
---
## Settings
### `get_settings` — GET
Returns current settings as **boolean flags only** (no raw key values):
```json
{
"gemini_key_set": true,
"bring_configured": false,
"tts_enabled": false,
"scale_enabled": true,
"demo_mode": false,
"settings_token_set": true
}
```
### `save_settings` — POST
Update server configuration. If `SETTINGS_TOKEN` is set, requires header:
```
X-Settings-Token: your_token
```
```json
{
"gemini_api_key": "...",
"bring_email": "...",
"scale_enabled": true,
"scale_gateway_url": "ws://127.0.0.1:8765"
}
```
---
## Error Reporting
### `report_error` — POST
Submit an automatic error report (creates a GitHub Issue).
```json
{
"type": "uncaught-error",
"message": "...",
"stack": "...",
"context": {}
}
```
Only creates an issue if:
- The client is running the latest released version
- The fingerprint hasn't been seen in the last 24 hours
---
## Anomaly Detection
### `inventory_anomalies` — GET
Returns inventory rows where stored quantity significantly differs from transaction history.
### `dismiss_anomaly` — POST
Dismiss an anomaly banner without changing inventory.
---
## Scale Integration
### `scale_relay` (SSE) — GET
Relays BLE scale readings from the gateway to the browser via Server-Sent Events (avoids HTTPS→WS mixed-content issues).
### `scale_ping` — GET
Check if the Scale Gateway is reachable.
### `scale_discover` — GET
Scan the local LAN for a running Scale Gateway instance.
+152
View File
@@ -0,0 +1,152 @@
# 📺 Android Kiosk App
The EverShelf Kiosk app turns any Android tablet into a dedicated, locked-down kitchen display running EverShelf full-screen.
---
## Download
**[⬇ Download latest APK](https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-kiosk.apk)**
> Current version: **v1.6.0** — requires Android 7.0+
---
## What it does
- Displays the EverShelf web app in a **full-screen WebView** (no browser chrome)
- **Locks the screen** with Android's `startLockTask` — home, recents, and back buttons are blocked
- Runs the **built-in BLE scale gateway** as an integrated foreground service — no external app required
- Provides a **native TTS bridge** so Cooking Mode reads steps aloud via Android TextToSpeech
- Auto-detects your EverShelf server on the LAN with a **smart discovery scanner**
- Reports errors and install failures back to the developer automatically
---
## Setup Wizard (6 steps)
The wizard runs automatically on first launch.
### Step 1 — Language
Select the app and web interface language (Italian, English, German).
### Step 2 — Welcome
Overview of what the wizard will configure.
### Step 3 — Permissions
Grant camera, microphone, and storage permissions needed by the web app.
The button transforms from **"Grant permissions"** to **"✅ Permissions granted — Continue →"** (green) once all permissions are granted.
### Step 4 — Server URL
Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`).
**Or tap "Auto-discover"** to let the wizard scan your LAN:
- 60 parallel threads, TCP pre-check, ports 80/443/8080/8443
- Only scans your actual Wi-Fi/Ethernet subnet (VPN and cellular interfaces ignored)
- Real-time feedback as hosts are tested
### Step 5 — Smart Scale
If you have a Bluetooth LE smart scale, configure it here:
1. Tap **"Yes, I have a scale"** — the app scans for nearby BLE devices
2. Tap your scale in the list (devices most likely to be scales are marked with ⭐)
3. On selection, the app automatically writes `scale_enabled=true` and `scale_gateway_url=ws://127.0.0.1:8765` to your EverShelf server
The BLE gateway runs as a built-in foreground service — **no external APK needed**.
### Step 6 — Screensaver
Choose whether the screen should go dark after inactivity.
### Summary
All done — the web app loads in full-screen kiosk mode.
---
## Header Overlay Buttons
Three buttons are injected into the top-left of the web header by the kiosk app:
| Button | Action |
|--------|--------|
| **✕** | Exit kiosk mode (confirmation dialog) |
| **↻** | Hard-refresh — clears WebView cache and reloads the app |
| **⚙️** | Open EverShelf Settings |
The native Android settings button is permanently hidden once the overlay is injected — the **⚙️** web button replaces it entirely.
---
## Exiting Kiosk Mode
Tap the **✕** button in the header overlay (top-left). A confirmation dialog appears.
---
## Hard Refresh
Tap the **↻** button in the header to clear the WebView cache and reload the latest version of the web app.
---
## Update Notifications
Every 6 hours the app checks GitHub releases. If a newer version is available, a banner appears with a one-tap download and install flow.
---
## Native TTS Bridge
When Cooking Mode reads recipe steps, the kiosk app:
1. Intercepts the TTS call from the web app via a JavaScript bridge
2. Uses the Android `TextToSpeech` engine directly
3. Falls back to the browser Web Speech API if the bridge is unavailable
No internet connection required for TTS. No extra voice packs to install.
---
## SSL / Self-signed Certificates
The WebView accepts self-signed certificates automatically. No configuration needed for local HTTPS servers.
---
## Troubleshooting
### "Server non trovato" during auto-discovery
- Make sure your tablet and server are on the same Wi-Fi network
- Ensure the server is not on a VPN-only interface
- Try entering the URL manually
### Screen pinning / back button not working
- Screen pinning requires the app to be set as Device Owner or the user to confirm the pin prompt
- Some Android skins (Samsung, Xiaomi) may require additional accessibility permissions
### App crashes on startup
- Force-stop the app, clear its data (Settings → Apps → EverShelf Kiosk → Clear data), and relaunch
---
## Building from Source
```bash
cd evershelf-kiosk
./gradlew assembleRelease
# APK: app/build/outputs/apk/release/app-release.apk
```
Requires Android Studio or JDK 17+ with the Android SDK.
---
## Permissions
| Permission | Purpose |
|-----------|---------|
| `INTERNET` | Load the EverShelf web app |
| `CAMERA` | Barcode scanning and AI photo identification |
| `RECORD_AUDIO` | Voice input in AI chat |
| `WAKE_LOCK` | Keep the screen on |
| `REQUEST_INSTALL_PACKAGES` | Over-the-air kiosk self-updates (installs new APK from GitHub releases) |
| `ACCESS_WIFI_STATE` | LAN auto-discovery |
| `REORDER_TASKS` | Bring the kiosk app to foreground when needed |
+157
View File
@@ -0,0 +1,157 @@
# ⚙️ Configuration
EverShelf is configured via a `.env` file in the project root. Copy `.env.example` to `.env` and edit it — the app reads this file on every API call.
**Never commit `.env` to Git.** It is already in `.gitignore`.
---
## Full `.env` Reference
```ini
# ─────────────────────────────────────────────
# AI — Google Gemini
# ─────────────────────────────────────────────
# Your Gemini API key (required for all AI features)
# Get one free at: https://aistudio.google.com/app/apikey
GEMINI_API_KEY=
# ─────────────────────────────────────────────
# Shopping List — Bring! Integration
# ─────────────────────────────────────────────
# Your Bring! account credentials
# Leave blank to disable Bring! integration
BRING_EMAIL=
BRING_PASSWORD=
# ─────────────────────────────────────────────
# Text-to-Speech (for Cooking Mode)
# ─────────────────────────────────────────────
# URL to a TTS endpoint (e.g. Home Assistant event endpoint)
TTS_URL=
# Bearer token for the TTS endpoint
TTS_TOKEN=
# Set to true to enable server-side TTS (the browser Web Speech API is always used as fallback)
TTS_ENABLED=false
# ─────────────────────────────────────────────
# Security
# ─────────────────────────────────────────────
# Protect the save_settings endpoint with a token
# If set, the Settings UI will prompt for this value before saving
# Validated with hash_equals() to prevent timing attacks
SETTINGS_TOKEN=
# ─────────────────────────────────────────────
# Demo / Public Mode
# ─────────────────────────────────────────────
# Set to true to block ALL write operations at the PHP router level
# Useful for public demos or read-only kiosk deployments
# Also activatable per-request via ?demo=1 URL parameter
DEMO_MODE=false
# ─────────────────────────────────────────────
# Scale Gateway
# ─────────────────────────────────────────────
# Enable the BLE scale integration
SCALE_ENABLED=false
# WebSocket URL of the Scale Gateway app running on the same device
# Default for Android kiosk: ws://127.0.0.1:8765
SCALE_GATEWAY_URL=ws://127.0.0.1:8765
```
---
## Settings UI
Most settings can also be configured from the browser via **Settings → ⚙️**:
| Setting | `.env` key | Notes |
|---------|-----------|-------|
| Gemini API key | `GEMINI_API_KEY` | Stored server-side, never exposed to browser |
| Bring! email | `BRING_EMAIL` | — |
| Bring! password | `BRING_PASSWORD` | — |
| TTS URL | `TTS_URL` | — |
| TTS token | `TTS_TOKEN` | — |
| TTS enabled | `TTS_ENABLED` | — |
| Scale enabled | `SCALE_ENABLED` | — |
| Scale gateway URL | `SCALE_GATEWAY_URL` | — |
| Settings token | `SETTINGS_TOKEN` | Write-only; current value never shown |
> **Security note:** `get_settings` returns only **boolean flags** (`gemini_key_set: true/false`), never raw key values. Raw values are only accessible server-side.
---
## Protecting Settings with a Token
If your EverShelf instance is accessible from untrusted networks, set `SETTINGS_TOKEN` to a strong random string:
```bash
# Generate a strong token
openssl rand -hex 32
```
```ini
SETTINGS_TOKEN=a3f9b2c1d4e5...
```
Users will be prompted for this token before any Settings save. If the token doesn't match, the request is rejected with HTTP 403.
---
## Demo Mode
Two ways to enable demo mode:
1. **Permanent:** Set `DEMO_MODE=true` in `.env`
2. **Per-session:** Append `?demo=1` to any URL (e.g. `https://evershelfproject.dadaloop.it/demo`)
In demo mode:
- All POST/write API calls return success without touching the database
- A "DEMO" badge appears in the header
- Gemini AI is treated as available (mock responses)
- Bring! write operations are silently no-op'd
- A mock pantry with sample data is loaded
---
## API Rate Limiting
EverShelf applies file-based rate limiting to protect AI endpoints:
| Tier | Limit | Endpoints |
|------|-------|-----------|
| Standard | 120 req/min | All general endpoints |
| AI | 15 req/min | `gemini_*`, `generate_recipe` |
| Strict | 5 req/min | `report_error` |
Rate limit state is stored in `data/rate_limits/`. To reset, delete the files in that directory.
---
## Database
EverShelf uses **SQLite** stored at `data/evershelf.db`. The file is created automatically on first run.
Schema migrations run automatically whenever `database.php` is loaded — no manual migration steps needed.
To back up the database:
```bash
cp data/evershelf.db data/backups/evershelf-$(date +%Y%m%d).db
```
Or use the included `backup.sh`:
```bash
./backup.sh
```
+164
View File
@@ -0,0 +1,164 @@
# 🤝 Contributing
Contributions of all kinds are welcome — bug fixes, new features, translations, documentation improvements.
---
## Getting Started
### 1. Fork and clone
```bash
git clone https://github.com/YOUR_USERNAME/EverShelf.git
cd EverShelf
```
### 2. Create a branch
```bash
git checkout -b feature/my-feature
# or
git checkout -b fix/my-bug-fix
```
### 3. Set up a local server
```bash
# Option A: PHP built-in server
php -S localhost:8080
# Option B: Docker
docker compose up -d
```
Open `http://localhost:8080` in your browser.
### 4. Make your changes
The app has **no build step**. Edit files directly and refresh the browser.
Key files:
- `assets/js/app.js` — all frontend logic
- `assets/css/style.css` — all styles
- `api/index.php` — all API endpoints
- `api/database.php` — SQLite schema and migrations
- `translations/*.json` — i18n strings
### 5. Test
```bash
# Check PHP syntax
php -l api/index.php
php -l api/database.php
# Check JS syntax
node --check assets/js/app.js
```
There are no automated JS tests yet — manual testing in the browser is the current approach. If you add a feature, test the full flow: add, use, undo.
### 6. Commit
Use [Conventional Commits](https://www.conventionalcommits.org/):
```bash
git commit -m "feat(inventory): add bulk delete"
git commit -m "fix(scale): handle BLE disconnect during countdown"
git commit -m "docs: update kiosk setup guide"
git commit -m "chore: bump version to 1.8.0"
```
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
Scopes: `inventory`, `ai`, `shopping`, `cooking`, `scale`, `kiosk`, `gateway`, `webapp`, `api`, `db`
### 7. Push and open a PR
```bash
git push origin feature/my-feature
```
Open a Pull Request against the `develop` branch (not `main`).
---
## Branch Strategy
| Branch | Purpose |
|--------|---------|
| `main` | Production — auto-deployed, never commit directly |
| `develop` | Integration branch — all PRs target here |
| `feature/*` | New features |
| `fix/*` | Bug fixes |
CI auto-merges `develop → main` on every push to `develop`.
---
## CI / CD Pipeline
GitHub Actions runs on every push to `develop` and `main`:
1. **PHP lint**`php -l` on all PHP files
2. **JS syntax check**`node --check assets/js/app.js`
3. **Translation validation** — checks that all language files have the same keys
4. **Docker build** — verifies the Docker image builds successfully
5. **Android build** — (on tagged commits) builds Kiosk and Scale Gateway APKs
---
## Adding Translations
See the full guide in [Translations](Translations).
Short version:
1. Copy `translations/it.json``translations/xx.json`
2. Translate all values
3. Add `'xx'` to `SUPPORTED_LANGUAGES` in `app.js`
4. Open a PR
---
## Reporting Bugs
Open an issue on GitHub. Include:
- Steps to reproduce
- Expected vs. actual behaviour
- Browser/OS version
- Any console errors (F12 → Console)
---
## Code Style
- **PHP:** PSR-12, 4-space indent, type hints where practical
- **JavaScript:** ES2020+, `async/await`, no frameworks, 4-space indent
- **CSS:** BEM-ish class names, CSS custom properties for theming
- **SQL:** parameterized queries (PDO), no raw string interpolation
---
## Adding a New API Endpoint
1. Add a `case 'my_action':` to the router in `api/index.php`
2. Implement `function myAction(PDO $db): void`
3. Add the endpoint to `docs/openapi.yaml`
4. Add translations for any new UI strings to all 3 language files
---
## Security
If you find a security vulnerability, **do not open a public issue**. Email [evershelfproject@gmail.com](mailto:evershelfproject@gmail.com) directly.
Relevant resources:
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- All SQL must use PDO prepared statements
- Never expose API keys in API responses (boolean flags only)
- Use `hash_equals()` for token comparison
---
## License
By contributing you agree that your code will be licensed under the [MIT License](https://github.com/dadaloop82/EverShelf/blob/main/LICENSE).
+162
View File
@@ -0,0 +1,162 @@
# ❓ FAQ & Troubleshooting
---
## Installation
### The app shows a blank page after setup
- Open the browser console (F12 → Console) and check for errors
- Make sure PHP is running and `api/index.php` is reachable: visit `https://your-server/dispensa/api/index.php?action=get_settings` — it should return JSON
- Check your web server error log: `tail -f /var/log/apache2/error.log`
### Camera doesn't work / barcode scanner won't open
Camera access requires **HTTPS**. On plain HTTP, browsers block `getUserMedia()`.
- Set up HTTPS with Let's Encrypt, Caddy, or a self-signed certificate
- On Android, you can also add a security exception in Chrome: `chrome://flags/#allow-insecure-localhost`
### "Permission denied" error for the data directory
```bash
chmod 755 data/
chown -R www-data:www-data data/
```
### Docker container exits immediately
```bash
docker compose logs evershelf
```
Usually a permission issue on the mounted `data/` volume. Try:
```bash
docker compose down
rm -rf data/
mkdir data/
docker compose up -d
```
---
## AI Features
### "AI not available" error
1. Check that `GEMINI_API_KEY` is set in `.env`
2. Verify the key is valid at [aistudio.google.com](https://aistudio.google.com)
3. Check that you haven't exceeded the free tier quota (15 req/min, 1500 req/day)
4. Look for errors in the PHP error log
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
max_execution_time = 120
```
---
## Shopping List (Bring!)
### "Bring! not configured" message in the shopping tab
Add your Bring! credentials to `.env`:
```ini
BRING_EMAIL=your@email.com
BRING_PASSWORD=yourpassword
```
### Items aren't syncing to Bring!
- Verify your credentials are correct by logging into [getbring.com](https://web.getbring.com/)
- Check for rate-limit errors in the PHP error log — Bring! has API limits
---
## Scale Integration
### Scale readings don't appear in EverShelf
1. Confirm the gateway app is running and shows the WebSocket URL
2. Check the Gateway URL in EverShelf Settings matches exactly
3. Make sure both the Android device and the EverShelf server are on the same network
4. Look at the scale status indicator (⚖️) in the header — "disconnected" means no WebSocket connection
### Scale shows weight but form doesn't auto-fill
- The auto-fill only triggers for products with unit `g` or `ml`
- Make sure you tapped **"⚖️ Read Scale"** first to activate the scale modal
- The weight must stabilize (stay within 10g) for the countdown to start
### Bluetooth scale not appearing in the gateway app
- Wake up the scale (step on it or press its button)
- Make sure Bluetooth and Location permissions are granted to the gateway app (Location is required by Android for BLE scanning)
- Restart the gateway app
---
## Kiosk App
### Setup wizard can't find my server
- Make sure the tablet is on the same Wi-Fi network as the server
- Try entering the URL manually instead of using auto-discovery
- Check that the server responds on the expected port (80/443/8080/8443)
### Kiosk app update fails
The kiosk checks for a new release every 6 hours and downloads it from GitHub. If the install fails:
| Symptom | Fix |
|---------|-----|
| "Install from unknown sources" dialog | Enable the setting for the EverShelf Kiosk app in Android Settings |
| Persistent failure after download | Force-stop the app, clear its data, and relaunch the update flow |
| Not enough space | Free up storage on the device |
### Exit button (✕) is not visible
Three buttons are always visible in the kiosk header overlay: **✕** (exit), **↻** (refresh), **⚙️** (settings). If the page failed to load entirely, tap **↻** first. If nothing is visible, restart the device.
### App is stuck in kiosk mode after a crash
Restart the device. Screen pinning is released on reboot.
---
## General
### The version shown in the app is outdated
The version is cached by the browser. Do a hard refresh:
- Desktop: `Ctrl+Shift+R` / `Cmd+Shift+R`
- Android: tap the ↻ button (kiosk) or clear site data in Chrome settings
### Transactions are missing from the log
The log shows the last 50 entries by default. Tap **"Load more"** to load more. Entries older than the database creation date won't appear.
### "Can only undo transactions within 24 hours"
The undo window is 24 hours. For older operations, manually correct the inventory via the Edit function on the affected product.
### Error reports keep creating duplicate GitHub issues
EverShelf uses a fingerprint to deduplicate — the same error from the same device won't create a new issue within 24 hours. If you're seeing duplicates, check the `data/rate_limits/` folder and clear old files.
---
## Getting Help
- **Open an issue:** [github.com/dadaloop82/EverShelf/issues](https://github.com/dadaloop82/EverShelf/issues)
- **Email:** [evershelfproject@gmail.com](mailto:evershelfproject@gmail.com)
- **Try the demo:** [evershelfproject.dadaloop.it/demo](https://evershelfproject.dadaloop.it/demo)
When reporting a bug, include:
1. EverShelf version (shown in the header as `v1.x.x`)
2. Browser and OS
3. Steps to reproduce
4. Any error messages from the browser console (F12)
+219
View File
@@ -0,0 +1,219 @@
# ✨ Features
A complete walkthrough of EverShelf's features.
---
## 📦 Inventory Management
### Adding Products
- Tap **** to open the add form
- Search by name or scan a barcode
- Select storage location: Pantry, Fridge, Freezer, or a custom location
- Enter quantity and expiry date (or let AI estimate it)
- Mark as vacuum-sealed or opened for adjusted shelf-life calculation
### Barcode Scanning
Tap the barcode icon to open the camera scanner (QuaggaJS). The app:
1. Checks your local database first
2. Falls back to [Open Food Facts](https://world.openfoodfacts.org/) for unknown barcodes
3. Pre-fills the product form with name, brand, category
### AI Product Identification
Point the camera at any product — Gemini identifies it and:
- Shows matching products **already in your pantry** first
- Suggests a new product entry with pre-filled fields
- Provides a storage location hint and estimated shelf-life
### Storage Locations
| Location | Icon | Notes |
|----------|------|-------|
| Pantry | 🏠 | Room temperature |
| Fridge | ❄️ | Refrigerated |
| Freezer | 🧊 | Frozen |
| Custom | 📦 | Any name you choose |
### Opened Product Tracking
When you partially use a product and mark it as "opened":
- Shelf-life is recalculated from the opening date
- Uses AI (Gemini) + per-category rules (e.g. fish: 2 days, milk: 3 days)
- Whole sealed packages always keep their original manufacturer expiry
- Products with mixed whole + fractional units show as two separate entries
### Vacuum-Sealed Support
Mark any product as vacuum-sealed to extend its estimated expiry date (typically 23× the normal shelf-life).
---
## 🤖 AI Features (Google Gemini)
All AI features require a `GEMINI_API_KEY` in `.env`. They degrade gracefully when the key is missing or quota is exceeded.
### Expiry Date Reading
Photograph the label on a product — Gemini extracts the expiry date and fills the field automatically.
### Product Identification
Camera-based identification with pantry matching. See [Adding Products](#adding-products) above.
### Storage & Shelf-life Hint
When adding a new product, a background Gemini call suggests:
- Optimal storage location
- Estimated shelf-life in days
Shown as an inline AI badge next to the expiry estimate. Does not block the form.
### Recipe Generation
Tap **🍳 Recipes** → **Generate Recipe** to get a recipe using:
- Ingredients about to expire (prioritised)
- What's currently in your pantry
- Your language preference
Recipes stream live via Server-Sent Events so results appear as they are generated.
### AI Chat Assistant
Open **💬 Chat** to ask questions like:
- "What can I make with eggs and pasta?"
- "How long does cooked ham last once opened in the fridge?"
- "Suggest a quick snack"
The assistant knows your current inventory.
### Shopping Suggestions with Tips
Smart shopping predictions include a short AI-generated practical tip per item (e.g. "Buy the 2 kg bag — it freezes well").
### Anomaly Explanation
When the dashboard shows a suspicious quantity banner, tap **🤖 Spiega** to get a plain-language explanation of why the discrepancy likely occurred and what to do about it.
### Model Fallback
All AI endpoints try `gemini-2.5-flash` first and automatically fall back to `gemini-2.0-flash` if unavailable.
---
## 🛒 Shopping List (Bring! Integration)
Configure `BRING_EMAIL` and `BRING_PASSWORD` in `.env` to enable.
### Features
- **View and manage** your Bring! list inside EverShelf
- **Auto-add on depletion** — when stock hits zero, the product is added to Bring! automatically
- **Auto-remove on scan** — scanning a product in removes it from the shopping list
- **Generic names** — products are grouped by type ("Latte", "Panna da cucina") not brand, keeping the list clean
- **Auto-migration** — items already on Bring! are silently renamed to their generic name on list load
- **Catalog coverage** — 100+ product types mapped to Bring! catalog keys for icons and categories in the Bring! app
- **AI fallback** — unknown product types use Gemini to determine the best generic name
---
## 🍳 Cooking Mode
Start cooking mode from any recipe by tapping **▶ Start Cooking**.
### Features
- **Step-by-step guidance** — fullscreen, distraction-free interface
- **Text-to-Speech** — each step is read aloud automatically when you navigate; supports:
- Browser Web Speech API (default)
- Native Android TTS (kiosk app)
- Custom REST endpoint (e.g. Home Assistant)
- **Built-in timers** — automatic timer suggestions based on recipe text; 10-second vocal countdown warning before expiry
- **Ingredient tracking** — mark ingredients as used; leftover quantities prompt a "move to another location" flow
- **Recipe completion** — "Buon appetito!" *(Enjoy your meal!)* spoken on the last step
---
## 📊 Dashboard
### Inventory Overview
Three stat cards at the top show item counts for Pantry, Fridge, and Freezer with animated skeleton loading while data fetches.
### Expiry Alerts Banner
Priority-sorted notifications for:
- Expired products (with safety assessment — green ✅ safe, amber 👀 check, red 🚫 danger)
- Products expiring within 3 days
Actions per item: Use, Throw away, Edit, Dismiss. Swipe or tap arrows to navigate.
### Anomaly Banner
Highlights suspicious quantities (e.g. "You have 0 eggs but used 12 this month"). Actions:
- One-tap correction to the suggested quantity
- Inline edit with free-form quantity
- "🤖 Explain" for AI explanation
- Dismiss (with current quantity shown: "The quantity is correct (2 pcs)")
### Anti-Waste Report
Shows your waste rate vs. the national average with an estimated annual kg of food wasted.
### Quick Recipe Bar
One-tap recipe suggestion using the ingredients closest to expiry.
---
## 📱 Progressive Web App (PWA)
EverShelf is installable as a PWA on any device:
1. Open in Chrome/Safari/Edge
2. Tap **"Add to Home Screen"** (browser menu)
3. Launch from the home screen like a native app
Features:
- Offline-capable shell (assets cached)
- Full-screen mode on mobile
- Multi-device: all data syncs via the shared server
---
## 🔔 Update Notifications
When a new EverShelf release is published on GitHub, a small pill appears in the header. Click it to see the changelog. Checked on load and every 30 minutes.
---
## 🌍 Multi-language
The app auto-detects your browser language. Supported: 🇮🇹 Italian, 🇬🇧 English, 🇩🇪 German.
Change the language in **Settings → Language**.
See [Translations](Translations) to add a new language.
---
## ↩ Transaction History & Undo
**Settings → Storico** shows all inventory operations (adds, uses, throws).
- Any operation within the **last 24 hours** shows a red ↩ undo button
- Tapping ↩ shows a 5-second countdown confirmation before reversing the transaction
- The original stock is restored and a counter-transaction is logged
---
## 🔒 Security Features
- API keys never exposed to the browser (`get_settings` returns boolean flags only)
- `save_settings` protected by optional `SETTINGS_TOKEN` (validated with `hash_equals`)
- `DEMO_MODE=true` blocks all write operations at the PHP router level
- Parameterized SQL queries (PDO prepared statements) throughout
- Input validation on all inventory operations (quantity bounds, location whitelist)
- See [Configuration](Configuration) for details
+96
View File
@@ -0,0 +1,96 @@
# 🏠 EverShelf Wiki
Welcome to the **EverShelf** project wiki — your complete reference for installation, configuration, features, and development.
---
## 🚀 Try it now
> **[▶ Live Demo](https://evershelfproject.dadaloop.it/demo)** — no installation, no login, full AI enabled
> **[🌐 Project Website](https://evershelfproject.dadaloop.it/)**
---
## 📚 Wiki Contents
| Page | Description |
|------|-------------|
| [Installation](Installation) | Docker, manual setup, HTTPS, web server config |
| [Configuration](Configuration) | `.env` reference — all options explained |
| [Features](Features) | Complete feature documentation |
| [API Reference](API-Reference) | All REST endpoints, parameters, and responses |
| [Android Kiosk](Android-Kiosk) | Tablet kiosk app setup and usage |
| [Scale Gateway](Scale-Gateway) | BLE smart scale integration |
| [Translations](Translations) | Adding and editing language files |
| [Contributing](Contributing) | Development workflow and PR process |
| [FAQ & Troubleshooting](FAQ) | Common issues and solutions |
---
## ✨ What is EverShelf?
EverShelf is a **self-hosted pantry management system** that runs entirely on your own server. It:
- Tracks food inventory across multiple storage locations (pantry, fridge, freezer, custom)
- Scans barcodes and uses **Google Gemini AI** to identify products from photos
- Suggests recipes based on what's in your pantry — especially items about to expire
- Predicts what you'll need to buy before you run out
- Integrates with the **Bring!** shopping list app
- Supports a **BLE smart scale** for weight-based tracking
- Runs as a **Progressive Web App** installable on any device
- Optionally pairs with a dedicated **Android kiosk tablet app**
All data stays on your server. No cloud, no subscriptions.
---
## 🆕 What's New
### v1.7.13 (2026-05-16)
- **Fix:** Kiosk Settings button (⚙️) added to the web overlay — tapping the camera button no longer accidentally opens kiosk settings
- **Fix:** Opened-item expiry badge is now consistent with the top banner: low-risk items (jams, condiments) show amber ⚠️ "Check soon" instead of misleading red ⛔ "Expired"
- **Cooking Mode:** 3D wheel UI with perspective card flip, ghost steps (prev/next), float animation, and full `prefers-reduced-motion` support
- **CI:** `data/category_ai_cache.json` added to `.gitignore`
- **Critical fix (DB):** Fresh-install crash resolved — `transactions` schema was missing the `undone` column
### v1.7.12 (2026-05-13)
- "Use first" banner now shows opening date and location instead of a confusing calculated expiry
- "Use All / Done" in recipes no longer deletes the inventory row — uses exact quantity instead
- Scan page fully redesigned: 2× zoom, torch, camera flip, 3 input tabs, AI Number OCR, recent products chips
- Anomaly detection: false positives eliminated (untracked direction removed, minimum 5 txn + 7-day span)
- AI price estimation for each Bring! shopping item with real-time dashboard total badge
- Kiosk v1.6.0: BLE scale gateway is now built-in — no separate APK needed
- Complete i18n: 934 keys per language
→ See the full [CHANGELOG](https://github.com/dadaloop82/EverShelf/blob/main/CHANGELOG.md)
---
## 📦 Repository Structure
```
EverShelf/
├── index.html # Single-page application entry point
├── manifest.json # PWA manifest
├── .env.example # Configuration template
├── api/
│ ├── index.php # Main API router
│ ├── database.php # SQLite schema + migrations
│ └── cron_smart_shopping.php # Background predictions job
├── assets/
│ ├── css/style.css
│ ├── js/app.js
│ └── img/
├── translations/ # i18n JSON files (it, en, de)
├── docs/openapi.yaml # OpenAPI 3.0 spec
├── evershelf-kiosk/ # Android kiosk app (Kotlin)
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin) — DEPRECATED, built into kiosk since v1.6.0
```
---
## 📄 License
MIT — free to use, modify, and distribute. See [LICENSE](https://github.com/dadaloop82/EverShelf/blob/main/LICENSE).
**Author:** Stimpfl Daniel — [evershelfproject@gmail.com](mailto:evershelfproject@gmail.com)
+233
View File
@@ -0,0 +1,233 @@
# 📦 Installation
EverShelf runs on any server with PHP 8.0+ and SQLite. Docker is the recommended approach for the fastest setup.
---
## Prerequisites
| Requirement | Minimum | Notes |
|-------------|---------|-------|
| PHP | 8.0+ | Extensions: `pdo_sqlite`, `curl`, `mbstring`, `json` |
| Web server | Apache 2.4+ or Nginx | Apache `.htaccess` included |
| SQLite | 3.x | Bundled with PHP on most distros |
| HTTPS | Recommended | Required for camera access on mobile browsers |
| RAM | 256 MB | 512 MB+ recommended if using AI features |
---
## Option A: Docker (recommended)
The fastest way to get started.
```bash
# 1. Clone the repository
git clone https://github.com/dadaloop82/EverShelf.git
cd EverShelf
# 2. Create your configuration
cp .env.example .env
nano .env # set GEMINI_API_KEY and other options
# 3. Start
docker compose up -d
# 4. Open in browser
# → http://localhost:8080
```
The Docker image:
- Uses PHP-Apache on Debian Bookworm slim
- Auto-creates the `data/` directory with correct permissions
- Exposes port `8080` by default (configurable in `docker-compose.yml`)
- Persists data in a named Docker volume
### Changing the port
Edit `docker-compose.yml`:
```yaml
ports:
- "8080:80" # change 8080 to your desired host port
```
### Using HTTPS with Docker
Add a reverse proxy (e.g. Traefik, Caddy, or Nginx Proxy Manager) in front of the container for automatic TLS.
---
## Option B: Manual (Apache)
```bash
# 1. Clone into your web root
git clone https://github.com/dadaloop82/EverShelf.git /var/www/html/dispensa
cd /var/www/html/dispensa
# 2. Create configuration
cp .env.example .env
nano .env
# 3. Set permissions on the data directory
chmod 755 data/
chown -R www-data:www-data data/
```
Make sure `mod_rewrite` is enabled:
```bash
sudo a2enmod rewrite
sudo systemctl restart apache2
```
Apache virtual host (or add to `.htaccess` which is already included):
```apache
<VirtualHost *:443>
ServerName evershelf.local
DocumentRoot /var/www/html/dispensa
<Directory /var/www/html/dispensa>
AllowOverride All
Require all granted
</Directory>
# Hide sensitive paths
<LocationMatch "^/(data|\.env|backup\.sh)">
Require all denied
</LocationMatch>
SSLEngine on
SSLCertificateFile /etc/ssl/certs/evershelf.crt
SSLCertificateKeyFile /etc/ssl/private/evershelf.key
</VirtualHost>
```
---
## Option C: Manual (Nginx)
```nginx
server {
listen 443 ssl;
server_name evershelf.local;
root /var/www/html/dispensa;
index index.html;
ssl_certificate /etc/ssl/certs/evershelf.crt;
ssl_certificate_key /etc/ssl/private/evershelf.key;
location /api/ {
try_files $uri $uri/ =404;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
# Block sensitive files
location ~ /\.env { deny all; }
location ~ /data/ { deny all; }
location ~ /backup\.sh { deny all; }
location / {
try_files $uri $uri/ /index.html;
}
}
```
---
## HTTPS Setup
Camera and microphone access (barcode scanning, voice) **require HTTPS** on all modern mobile browsers.
### Self-signed certificate (local network)
```bash
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout /etc/ssl/private/evershelf.key \
-out /etc/ssl/certs/evershelf.crt \
-subj "/CN=evershelf.local" \
-addext "subjectAltName=IP:192.168.1.100,DNS:evershelf.local"
```
Android will show a certificate warning — tap "Advanced → Proceed" once. The kiosk app accepts self-signed certificates automatically.
### Let's Encrypt (public server)
```bash
sudo apt install certbot python3-certbot-apache
sudo certbot --apache -d evershelf.yourdomain.com
```
### Caddy (automatic TLS)
```
evershelf.yourdomain.com {
root * /var/www/html/dispensa
php_fastcgi unix//run/php/php8.2-fpm.sock
file_server
respond /data/* 403
respond /.env 403
}
```
---
## Cron Job (optional)
For smart shopping predictions to stay up to date:
```bash
# Edit crontab
crontab -e
# Add (runs every 5 minutes)
*/5 * * * * php /var/www/html/dispensa/api/cron_smart_shopping.php >> /var/www/html/dispensa/data/cron.log 2>&1
```
---
## Backup (optional)
```bash
# Edit crontab
crontab -e
# Daily backup at 3 AM
0 3 * * * /var/www/html/dispensa/backup.sh
```
The `backup.sh` script copies `data/evershelf.db` to `data/backups/` with a timestamp.
---
## Updating
```bash
cd /var/www/html/dispensa
git pull origin main
# Database migrations run automatically on next page load
```
With Docker:
```bash
docker compose pull
docker compose up -d
```
---
## Post-installation
Once the app is running, open it in your browser and:
1. Go to **Settings** (⚙️ icon in the header)
2. Enter your **Gemini API key** (get one free at [aistudio.google.com](https://aistudio.google.com/app/apikey))
3. Optionally configure Bring!, TTS, and scale settings
4. Add your first product via the button or barcode scan
See [Configuration](Configuration) for the full list of settings.
+162
View File
@@ -0,0 +1,162 @@
# ⚠️ Scale Gateway — Deprecated
> **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)
---
## How it works
```
Smart Scale
│ (Bluetooth LE)
Android device (Scale Gateway app)
│ (WebSocket — ws://127.0.0.1:8765)
EverShelf Server (scale_relay.php — SSE relay)
│ (Server-Sent Events)
EverShelf Web App (auto-fills weight in add/use forms)
```
The Gateway runs a local WebSocket server on port **8765**. The EverShelf server proxies scale readings to the browser via SSE, avoiding HTTPS→WS mixed-content issues.
---
## Download
**[⬇ Download latest APK](https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk)**
> Current version: **v2.1.0** — requires Android 7.0+
---
## Supported Scales
| Protocol | BLE Service | Notes |
|----------|------------|-------|
| Bluetooth SIG Weight Scale | `0x181D` / char `0x2A9D` | Most compatible |
| Bluetooth SIG Body Composition | `0x181B` / char `0x2A9C` | Weight + body fat |
| Generic fallback | Any notifiable characteristic | Auto-heuristic for 100+ models |
**Verified compatible models:**
- Xiaomi Mi Body Composition Scale 2
- Renpho Smart Body Fat Scale
- Any scale supported by [openScale](https://github.com/oliexdev/openScale/wiki/Supported-scales)
---
## Setup
### 1. Install
Download and install the APK. You may need to enable "Install from unknown sources" in Android Settings.
### 2. Launch the app
The gateway server starts immediately. Note the **Gateway URL** shown (e.g. `ws://192.168.1.100:8765`).
### 3. Configure EverShelf
In EverShelf **Settings → Scale**:
- Enable scale integration
- Enter the Gateway URL (or let auto-discovery find it)
> **Kiosk users:** this is done automatically during setup.
### 4. Connect your scale
Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in the list to pair and connect.
---
## Using the Scale in EverShelf
When scale integration is enabled:
1. Open the **Add** or **Use** form for any product with unit `g` or `ml`
2. A **"⚖️ Read Scale"** button appears
3. Tap it — a live weight display appears with a stability indicator
4. Step on or place the product on the scale
5. When the reading stabilizes, a **5-second countdown** starts
6. The weight auto-fills the quantity field and the form submits
### Thresholds and de-duplication
- **10g threshold** — readings that haven't changed enough between products are ignored to prevent stale readings
- **12-second server-side dedup** — a second scale-triggered deduction of the same product within 12 seconds is rejected (guards against BLE multi-fire)
- **ml conversion** — when the product unit is `ml`, the weight in grams is accepted and a hint is shown: "weight in grams → will be converted to ml"
---
## Scale Status Indicator
The header of the EverShelf web app shows a real-time scale status icon (⚖️):
| State | Meaning |
|-------|---------|
| ⚖️ green | Connected and ready |
| ⚖️ amber | Searching / reconnecting |
| ⚖️ grey | Disconnected |
| ⚖️ red | Error |
---
## Update Notifications
Every 6 hours the gateway app checks GitHub releases. If a newer version is available, a banner appears with a one-tap download and install.
---
## Troubleshooting
### Scale not appearing in the Bluetooth list
- Make sure BLE is enabled on the Android device
- Step on/shake the scale to wake it up (most scales enter sleep mode quickly)
- Some scales only advertise while someone stands on them
### Weight not appearing in EverShelf
- Confirm the Gateway URL in EverShelf Settings matches the URL shown in the gateway app
- Check that the Android device and the EverShelf server are on the same network
- Tap "Disconnect / Reconnect" in the gateway app to refresh the WebSocket connection
### "Mixed content" error in browser
- Make sure you are accessing EverShelf over HTTPS (not plain HTTP)
- The SSE relay (`scale_relay.php`) handles the HTTP→WS bridging — ensure the relay script is reachable
---
## Building from Source
```bash
cd evershelf-scale-gateway
./gradlew assembleRelease
# APK: app/build/outputs/apk/release/app-release.apk
```
Requires Android Studio or JDK 17+ with the Android SDK.
---
## BLE Protocol Details
The gateway uses the following GATT profile order:
1. **Weight Scale** (`0x181D`) — standard weight only
2. **Body Composition** (`0x181B`) — weight + additional metrics
3. **Generic fallback** — subscribes to all notifiable characteristics and applies a heuristic parser that handles byte-order variations used by the majority of consumer smart scales
Weight values are extracted in kg, converted to grams, and broadcast over WebSocket as:
```json
{ "weight_g": 1234, "stable": true, "unit": "g" }
```
+143
View File
@@ -0,0 +1,143 @@
# 🌍 Translations
EverShelf uses JSON translation files in the `translations/` folder. The app auto-detects the browser language on load and falls back to English.
---
## Currently Supported Languages
| Language | File | Status |
|----------|------|--------|
| 🇮🇹 Italian | `translations/it.json` | ✅ Complete (base language) |
| 🇬🇧 English | `translations/en.json` | ✅ Complete |
| 🇩🇪 German | `translations/de.json` | ✅ Complete |
---
## Adding a New Language
### 1. Copy the base file
```bash
cp translations/it.json translations/fr.json
```
### 2. Translate all values
Open `fr.json` in your editor and translate every **value** (leave the **keys** unchanged).
```json
{
"app": {
"name": "EverShelf",
"loading": "Chargement..." ← translate this
},
"nav": {
"title": "🏠 EverShelf", ← keep emoji, translate text
"home": "Accueil"
}
}
```
**Rules:**
- Never change the key names (left side of `:`)
- Keep `{placeholder}` tokens unchanged — they are replaced at runtime
- Example: `"toast.added": "Added {name} to {location}"` — keep `{name}` and `{location}`
- Keep HTML tags if present (rare): `<strong>`, `<br>`
- Keep emojis (they are part of the UX design)
- Plurals: some keys have `_one` / `_many` variants — translate both
### 3. Register the language in the app
Open `assets/js/app.js` and find the `SUPPORTED_LANGUAGES` constant (near the top):
```js
const SUPPORTED_LANGUAGES = ['it', 'en', 'de'];
```
Add your language code:
```js
const SUPPORTED_LANGUAGES = ['it', 'en', 'de', 'fr'];
```
### 4. Add the language to `translations/` badge list
Update the `README.md` badge:
```markdown
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR-orange.svg)](translations/)
```
### 5. Test
Open the app with `?lang=fr` in the URL to force your language:
```
http://localhost:8080/?lang=fr
```
Check for missing keys — they will show the raw key name in the UI (e.g. `nav.title`).
### 6. Submit a PR
Open a pull request with your new `translations/fr.json` and the updated `app.js` line. See [Contributing](Contributing).
---
## Translation Key Structure
The file is a nested JSON object. Here are the main sections:
| Section | Description |
|---------|-------------|
| `app` | General app strings |
| `nav` | Navigation labels |
| `btn` | Button labels |
| `locations` | Storage location names |
| `categories` | Product category names |
| `dashboard` | Dashboard section titles |
| `inventory` | Inventory page strings |
| `use` | Use/consume form strings |
| `add` | Add product form strings |
| `scan` | Barcode scanner strings |
| `recipes` | Recipe page strings |
| `cooking` | Cooking mode strings |
| `shopping` | Shopping list strings |
| `log` | Transaction log strings |
| `settings` | Settings page strings |
| `scale` | Scale integration strings |
| `toast` | Toast notification messages |
| `error` | Error messages |
| `confirm` | Confirmation dialog strings |
---
## Updating Existing Translations
If a new feature adds keys to `it.json` (the base), you need to add the same keys to `en.json` and `de.json`.
The CI pipeline validates that all language files contain the same keys — a missing key will fail the build.
To check locally:
```bash
node -e "
const it = require('./translations/it.json');
const en = require('./translations/en.json');
// flatten and compare keys...
"
```
Or just open a PR — CI will flag any missing keys automatically.
---
## Language Detection Order
1. `?lang=xx` URL parameter (forces a specific language)
2. `localStorage.getItem('lang')` (last manually selected language)
3. `navigator.language` / `navigator.languages` (browser preference)
4. Fallback: `en`
Users can change the language in **Settings → Language**.
Binary file not shown.
Binary file not shown.
+167
View File
@@ -0,0 +1,167 @@
# EverShelf Kiosk
Android kiosk app for wall-mounted kitchen tablets. Full-screen WebView wrapper with integrated BLE scale gateway — no external apps required.
> **Version:** 1.6.0 (versionCode 10)
> **Package:** `it.dadaloop.evershelf.kiosk`
> **Min SDK:** Android 7.0 (API 24)
---
## Features
### Kiosk Mode
- **Full-screen WebView** — immersive mode hides status bar and navigation bar
- **True kiosk lock** — screen pinning (`startLockTask`) blocks home/recent/back buttons
- **Exit button (✕)** — visible in header, requires confirmation dialog to exit kiosk
- **Hard refresh (↻)** — clears WebView cache to pick up web app updates instantly
- **SSL support** — accepts self-signed certificates for local HTTPS servers
- **Update notifications** — checks GitHub releases every 6 hours, shows auto-dismiss banner
- **Native TTS bridge** — cooking mode voice readout uses Android TextToSpeech directly
- **Settings activity** — change server URL, test connection, re-run setup wizard
### BLE Scale Gateway (integrated, no external app)
- **Built-in BLE gateway**`GatewayService` foreground service handles BLE scanning and connection automatically when a scale is configured
- **WebSocket server** — exposes scale data on `ws://127.0.0.1:8765`, fully protocol-compatible with the legacy standalone gateway app (no webapp JS changes needed)
- **Auto-start** — service starts automatically on kiosk launch if a scale device is configured
- **Auto-reconnect** — reconnects automatically after 8 seconds if the BLE link drops
- **Multi-protocol** — supports Bluetooth SIG Weight Scale (`0x181D`/`0x2A9D`), Body Composition (`0x181B`/`0x2A9C`), QN/Yolanda scales, and 100+ models via generic fallback heuristic
### Setup Wizard (6 steps)
1. **Language** — choose Italian / English / German
2. **Welcome** — intro and privacy information
3. **Permissions** — camera, microphone, BLE permissions with in-wizard grant flow
4. **Server URL** — enter your EverShelf URL; auto-discovery scans the LAN (60 parallel threads, ports 80/443/8080/8443)
5. **Smart Scale** — optional: scan for BLE scales and select yours from the discovered device list (mandatory before proceeding if you choose "yes")
6. **Screensaver** — toggle display sleep after inactivity
---
## Architecture
```
KioskActivity (WebView — full-screen EverShelf)
├── SetupActivity (6-step wizard, shown on first launch only)
├── SettingsActivity (URL, scale status, re-run wizard)
├── Immersive mode (SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
├── Screen pinning (startLockTask / stopLockTask)
├── JS bridge (_kioskBridge: exit, hardReload)
└── GatewayService (foreground service — BLE + WebSocket)
├── BleScaleManager — BLE scanning, GATT, auto-reconnect
├── GatewayWebSocketServer — WebSocket server :8765
└── ScaleProtocol — multi-protocol BLE weight parser
```
The kiosk app is fully self-contained. No separate gateway app is required.
---
## Setup
1. Install the **EverShelf Kiosk** APK on your Android tablet
2. Launch the app — the setup wizard starts automatically
3. Choose your language
4. Grant camera, microphone and Bluetooth permissions when prompted
5. Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`) or use auto-discovery
6. If you have a Bluetooth scale: tap **"Yes, I have a scale"**, wait for the BLE scan, then tap your scale in the list
7. Done — the web app loads in full-screen kiosk mode
### Scale Configuration
BLE scale setup happens inside the kiosk app itself — **no external app needed**:
- During the **setup wizard (step 5)**, the app scans for nearby BLE scales and shows them in a list. Devices most likely to be scales are marked with ⭐.
- Tap a device to select it. The selection is saved and the "Next" button becomes enabled.
- From the **Settings screen**, you can restart the BLE service or reconfigure the scale device.
### Exiting Kiosk Mode
Tap the **✕** button in the header. A confirmation dialog appears — tap **"Exit"** to confirm.
---
## Permissions
| Permission | Purpose |
|---|---|
| `INTERNET` | Load EverShelf web app |
| `ACCESS_NETWORK_STATE` | Check connectivity |
| `ACCESS_WIFI_STATE` | LAN subnet detection for auto-discovery |
| `WAKE_LOCK` | Keep screen on |
| `CAMERA` | Barcode scanning, AI photo identification |
| `RECORD_AUDIO` | Voice input in chat assistant |
| `READ_MEDIA_IMAGES` / `READ_EXTERNAL_STORAGE` | Image access for AI scan |
| `REORDER_TASKS` | Bring kiosk to foreground |
| `BLUETOOTH` / `BLUETOOTH_ADMIN` | BLE (Android ≤ 11) |
| `BLUETOOTH_SCAN` / `BLUETOOTH_CONNECT` | BLE scan and connect (Android 12+) |
| `ACCESS_FINE_LOCATION` | Required for BLE scan on Android < 12 |
| `FOREGROUND_SERVICE` | Run BLE gateway as foreground service |
| `FOREGROUND_SERVICE_CONNECTED_DEVICE` | Service type for BLE (Android 14+) |
---
## Supported Scale Protocols
| Protocol | Service UUID | Notes |
|---|---|---|
| **Bluetooth SIG Weight Scale** | `0x181D` / char `0x2A9D` | Most compatible |
| **Bluetooth SIG Body Composition** | `0x181B` / char `0x2A9C` | Weight + body fat %, BMI |
| **QN/Yolanda** | Custom UUIDs | Xiaomi Mi Scale 2, Renpho, etc. |
| **Generic fallback** | Any notifiable characteristic | Auto-heuristic parsing for 100+ models |
### Verified compatible scales
- Xiaomi Mi Body Composition Scale 2
- Renpho Smart Body Fat Scale
- INEVIFIT Smart Body Fat Scale
- Any [openScale-compatible scale](https://github.com/oliexdev/openScale/wiki/Supported-scales)
---
## WebSocket Protocol
The built-in WebSocket server speaks the same protocol as the legacy standalone gateway app — the EverShelf webapp needs no changes.
**Server → client:**
```json
{"type":"status","state":"connected","device":"Mi Scale 2","battery":85}
{"type":"status","state":"disconnected"}
{"type":"weight","value":72.50,"unit":"kg","stable":true,"timestamp":1712345678000}
{"type":"pong"}
```
**Client → server:**
```json
{"type":"get_status"}
{"type":"get_weight"}
{"type":"ping"}
```
---
## Building
```bash
cd evershelf-kiosk
./gradlew assembleDebug
# APK at app/build/outputs/apk/debug/app-debug.apk
```
For release:
```bash
./gradlew assembleRelease
```
---
## Requirements
- Android 7.0+ (API 24)
- Bluetooth LE support (for scale integration)
- Network access to EverShelf server
---
## License
MIT — see [LICENSE](../LICENSE)
+60
View File
@@ -0,0 +1,60 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "it.dadaloop.evershelf.kiosk"
compileSdk = 34
defaultConfig {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 34
versionCode = 15
versionName = "1.7.14"
}
signingConfigs {
// Project keystore — same on every machine so OTA updates always work.
create("project") {
storeFile = file("../evershelf.jks")
storePassword = "evershelf123"
keyAlias = "evershelf"
keyPassword = "evershelf123"
}
}
buildTypes {
debug {
signingConfig = signingConfigs.getByName("project")
}
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("project")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
}
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.webkit:webkit:1.10.0")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("org.java-websocket:Java-WebSocket:1.5.5")
}
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Network -->
<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 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Camera & Microphone (for barcode scan, photo, voice) -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<!-- Storage (file upload/download) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Move task to front -->
<uses-permission android:name="android.permission.REORDER_TASKS" />
<!-- Self-update: install own APK at runtime -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- ── BLE Scale Gateway (integrated) ───────────────────────────── -->
<!-- Legacy BLE permissions (Android ≤ 11) -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- Fine location required for BLE scan on Android < 12 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<!-- Android 12+ BLE permissions -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Foreground service for keeping BLE+WebSocket alive -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<!-- BLE hardware — not required, app gracefully disables scale if absent -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar">
<activity
android:name=".KioskActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".SetupActivity"
android:exported="false"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".SettingsActivity"
android:exported="false"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar" />
<!-- GatewayService: runs BLE scan + WebSocket server as a foreground service -->
<service
android:name=".scale.GatewayService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<!-- FileProvider for serving the downloaded APK to the installer -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,354 @@
package it.dadaloop.evershelf.kiosk
import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import org.json.JSONObject
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
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 Kiosk.
*
* Sends structured JSON payloads to the EverShelf backend
* (POST /api/?action=report_error) which in turn creates or
* updates a GitHub Issue automatically.
*
* Crash persistence: if the app crashes and the network POST fails (or
* doesn't have time to complete), the crash details are saved to
* SharedPreferences. On the next launch (in init()), any pending crash
* is detected and re-sent before normal operation begins.
*
* Usage:
* // In Application or Activity onCreate:
* ErrorReporter.init(this, prefs.getString("evershelf_url", "")!!)
*
* // To report a caught exception:
* ErrorReporter.report(e, "myMethod", mapOf("extra" to "data"))
*
* // To report a non-exception event:
* ErrorReporter.reportMessage("webview-crash", "WebView died unexpectedly")
*/
object ErrorReporter {
private const val TAG = "EverShelfErrorReporter"
// SharedPreferences for crash persistence
private const val PREFS_NAME = "evershelf_kiosk_errors"
private const val KEY_PENDING = "pending_crash_json"
private const val KEY_WAS_RUNNING = "was_running_dirty"
private const val KEY_LAST_EXIT_TS = "last_reported_exit_ts"
private val executor = Executors.newSingleThreadExecutor()
// Fingerprints already sent in this process to avoid flooding
private val sentFingerprints = mutableSetOf<String>()
private var serverBaseUrl: String = ""
private var appVersion: String = ""
private var deviceInfo: String = ""
private lateinit var appContext: Context
/**
* Call once (e.g. in KioskActivity.onCreate) before reporting any errors.
* @param context Application or Activity context.
* @param baseUrl The EverShelf server URL, e.g. "http://192.168.1.10:8080"
*/
fun init(context: Context, baseUrl: String) {
appContext = context.applicationContext
serverBaseUrl = baseUrl.trimEnd('/')
try {
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
appVersion = pi.versionName ?: "unknown"
} catch (_: Exception) {}
deviceInfo = buildString {
val mfr = Build.MANUFACTURER.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.PRODUCT.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.BOARD
val model = Build.MODEL.takeIf { it.isNotBlank() && it != "unknown" }
?: Build.HARDWARE
append("$mfr $model (Android ${Build.VERSION.RELEASE}/${Build.VERSION.SDK_INT})")
}
// Send any crash that was saved to prefs during a previous session
sendPendingCrash()
// Detect ANR / OOM / native crashes from the previous run
detectPreviousCrash()
// Install a global UncaughtExceptionHandler so ANY unhandled crash is reported
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
val type = "uncaught-exception"
val message = throwable.message ?: throwable.javaClass.simpleName
val stack = throwable.stackTraceToString()
val ctx = mapOf("thread" to thread.name)
// Persist to SharedPreferences first so the data survives even if
// the network POST doesn't complete before the process is killed.
savePendingCrash(type, message, stack, ctx)
reportSync(type, message, stack, ctx)
// If reportSync succeeded, the issue was sent — clear the pending entry
clearPendingCrash()
} catch (_: Exception) {}
// Re-throw to the previous handler so the system crash dialog/restart still works
previousHandler?.uncaughtException(thread, throwable)
}
}
/**
* Call from Activity.onDestroy() on a *clean* exit (back-pressed, settings, shutdown).
* Clears the dirty-launch sentinel so the next start does not report a false positive.
*/
fun markCleanStop() {
if (::appContext.isInitialized) {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(KEY_WAS_RUNNING, false).apply()
}
}
/**
* Report a caught [Throwable] asynchronously (does not block UI thread).
*/
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)
reportAsync(
type = "kiosk-exception",
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
stack = throwable.stackTraceToString(),
context = ctx
)
}
/**
* Report a non-exception message (e.g. WebView page error, network failure).
* @param forceReport if true, bypasses the in-session dedup so retries are always sent.
*/
fun reportMessage(
type: String,
message: String,
extra: Map<String, Any?> = emptyMap(),
forceReport: Boolean = false
) {
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
ctx.putAll(extra)
reportAsync(type = type, message = message, stack = "", context = ctx, force = forceReport)
}
// ── Internal ─────────────────────────────────────────────────────────────
/**
* Detects whether the *previous* run of the app ended with a crash, ANR or OOM kill.
*
* On Android 11+ (API 30) we use [ActivityManager.getHistoricalProcessExitReasons] which
* gives the exact reason and (for Java crashes) a stack trace.
*
* On Android 710 we use a "dirty-launch sentinel": a boolean in SharedPreferences that is
* set to `true` on every start and `false` only when the activity is destroyed cleanly via
* [markCleanStop]. If it is still `true` on the next start, the previous run was not clean.
*/
private fun detectPreviousCrash() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
detectExitReasonApi30()
} else {
// API 2429: dirty-launch sentinel
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
if (prefs.getBoolean(KEY_WAS_RUNNING, false)) {
reportAsync(
type = "crash-sentinel",
message = "App was not cleanly shut down on previous run (ANR / OOM / native crash suspected).",
stack = "",
context = mapOf(
"device" to deviceInfo,
"note" to "Detected via dirty-launch sentinel (API ${Build.VERSION.SDK_INT})"
)
)
}
}
// Mark this launch as running — will be cleared by markCleanStop() on clean exit
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(KEY_WAS_RUNNING, true).apply()
}
@RequiresApi(Build.VERSION_CODES.R)
private fun detectExitReasonApi30() {
try {
val am = appContext.getSystemService(ActivityManager::class.java) ?: return
// Check the last 5 exits; stop at the first we already reported
val exits = am.getHistoricalProcessExitReasons(null, 0, 5)
if (exits.isEmpty()) return
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val lastReportedTs = prefs.getLong(KEY_LAST_EXIT_TS, 0L)
val crashReasons = setOf(
ApplicationExitInfo.REASON_CRASH,
ApplicationExitInfo.REASON_CRASH_NATIVE,
ApplicationExitInfo.REASON_ANR,
ApplicationExitInfo.REASON_LOW_MEMORY
)
var newestTs = lastReportedTs
for (exit in exits) {
if (exit.timestamp <= lastReportedTs) continue // already reported
if (exit.reason !in crashReasons) continue
val reasonName = when (exit.reason) {
ApplicationExitInfo.REASON_CRASH -> "crash-java"
ApplicationExitInfo.REASON_CRASH_NATIVE -> "crash-native"
ApplicationExitInfo.REASON_ANR -> "anr"
ApplicationExitInfo.REASON_LOW_MEMORY -> "oom-kill"
else -> "exit-${exit.reason}"
}
val msg = exit.description?.takeIf { it.isNotEmpty() }
?: "${exit.processName ?: "app"} terminated (reason ${exit.reason})"
// Java crashes include a tombstone trace — read up to 4KB
var stack = ""
try {
exit.traceInputStream?.bufferedReader()?.use { stack = it.readText().take(4000) }
} catch (_: Exception) {}
val ctx = mutableMapOf<String, Any?>(
"device" to deviceInfo,
"reason" to exit.reason,
"process" to (exit.processName ?: ""),
"crash_ts" to SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(exit.timestamp)),
"note" to "Detected via ApplicationExitInfo on restart (API ${Build.VERSION.SDK_INT})"
)
reportAsync(type = reasonName, message = msg, stack = stack, context = ctx)
if (exit.timestamp > newestTs) newestTs = exit.timestamp
}
if (newestTs > lastReportedTs) {
prefs.edit().putLong(KEY_LAST_EXIT_TS, newestTs).apply()
}
} catch (_: Exception) {}
}
private fun fingerprint(type: String, message: String): String {
val key = "$type:${message.take(120)}"
return key.hashCode().toString(16)
}
private fun reportAsync(type: String, message: String, stack: String, context: Map<String, Any?>, force: Boolean = false) {
val fp = fingerprint(type, message)
if (!force) {
synchronized(sentFingerprints) {
if (!sentFingerprints.add(fp)) return // already reported this session
}
} else {
synchronized(sentFingerprints) { sentFingerprints.add(fp) }
}
executor.execute { doPost(type, message, stack, context) }
}
/** Synchronous variant used only in the UncaughtExceptionHandler (already off main thread). */
private fun reportSync(type: String, message: String, stack: String, context: Map<String, Any?>) {
val fp = fingerprint(type, message)
synchronized(sentFingerprints) { sentFingerprints.add(fp) }
doPost(type, message, stack, context)
}
// ── Crash persistence helpers ─────────────────────────────────────────────
private fun savePendingCrash(type: String, message: String, stack: String, context: Map<String, Any?>) {
try {
val ctxJson = JSONObject()
context.forEach { (k, v) -> ctxJson.put(k, v) }
val payload = JSONObject().apply {
put("type", type)
put("message", message)
put("stack", stack)
put("context", ctxJson)
put("version", appVersion)
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
}
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(KEY_PENDING, payload.toString()).apply()
} catch (_: Exception) {}
}
private fun clearPendingCrash() {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().remove(KEY_PENDING).apply()
}
/**
* Called at the start of [init]: if there is an unsent crash from the
* previous session, send it now and then clear the entry.
*/
private fun sendPendingCrash() {
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_PENDING, null) ?: return
// Clear immediately so we don't re-send if THIS launch also crashes
clearPendingCrash()
executor.execute {
try {
val p = JSONObject(json)
val type = p.optString("type", "uncaught-exception")
val message = p.optString("message", "")
val stack = p.optString("stack", "")
val savedTs = p.optString("ts", "")
val ctxJson = p.optJSONObject("context") ?: JSONObject()
val ctx = mutableMapOf<String, Any?>("note" to "Sent on next launch after crash")
if (savedTs.isNotEmpty()) ctx["crash_ts"] = savedTs
ctxJson.keys().forEach { k -> ctx[k] = ctxJson.opt(k) }
doPost("$type-survived", message, stack, ctx)
} catch (_: Exception) {}
}
}
private fun doPost(type: String, message: String, stack: String, context: Map<String, Any?>) {
val url = serverBaseUrl.ifEmpty { return }
val endpoint = "$url/api/?action=report_error"
try {
val ctxJson = JSONObject()
context.forEach { (k, v) -> ctxJson.put(k, v) }
val payload = JSONObject().apply {
put("source", "kiosk")
put("type", type)
put("message", message)
put("stack", stack)
put("context", ctxJson)
put("version", appVersion)
put("user_agent", "EverShelf-Kiosk/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
put("url", url)
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
}
val conn = URL(endpoint).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
conn.setRequestProperty("Accept", "application/json")
conn.doOutput = true
conn.connectTimeout = 8000
conn.readTimeout = 8000
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
val responseCode = conn.responseCode
conn.disconnect()
Log.d(TAG, "Reported '$type' → HTTP $responseCode")
} catch (e: Exception) {
// Never rethrow from the error reporter itself
Log.w(TAG, "Failed to report error '$type': ${e.message}")
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
package it.dadaloop.evershelf.kiosk
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.button.MaterialButton
import com.google.android.material.switchmaterial.SwitchMaterial
import it.dadaloop.evershelf.kiosk.scale.GatewayService
import java.net.URL
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class SettingsActivity : AppCompatActivity() {
private lateinit var prefs: SharedPreferences
private lateinit var urlEdit: EditText
companion object {
private const val PREFS_NAME = "evershelf_kiosk"
private const val KEY_URL = "evershelf_url"
private const val KEY_SETUP_COMPLETE = "setup_complete"
private const val KEY_SCREENSAVER = "screensaver_enabled"
private const val KEY_HAS_SCALE = "has_scale"
}
override fun attachBaseContext(newBase: Context) {
val lang = newBase.getSharedPreferences("evershelf_kiosk", Context.MODE_PRIVATE)
.getString("kiosk_language", null)
super.attachBaseContext(if (lang != null) SetupActivity.applyLocale(newBase, lang) else newBase)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
urlEdit = findViewById(R.id.urlEdit)
urlEdit.setText(prefs.getString(KEY_URL, "") ?: "")
// Screensaver toggle (default OFF = keep screen on)
val switchScreensaver = findViewById<SwitchMaterial>(R.id.switchScreensaver)
switchScreensaver.isChecked = prefs.getBoolean(KEY_SCREENSAVER, false)
// ── Smart Scale (BLE gateway service) ──────────────────────────────
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
val deviceName = prefs.getString("scale_device_name", null)
val deviceAddr = prefs.getString("scale_device_address", null)
val statusView = findViewById<TextView>(R.id.scaleGatewayStatus)
val deviceView = findViewById<TextView>(R.id.scaleDeviceInfo)
val btnScaleAction = findViewById<MaterialButton>(R.id.btnConfigureGateway)
val btnReconfigureScale = findViewById<MaterialButton>(R.id.btnReconfigureScale)
when {
!hasScale || deviceAddr == null -> {
statusView.text = "Non configurata"
statusView.setTextColor(0xFF94a3b8.toInt())
deviceView.text = "Nessuna bilancia configurata — riesegui il wizard per aggiungerne una"
btnScaleAction.visibility = android.view.View.VISIBLE
btnScaleAction.text = "⚙️ Configura bilancia"
btnScaleAction.setOnClickListener {
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, false).apply()
startActivity(Intent(this, SetupActivity::class.java))
finish()
}
}
else -> {
statusView.text = "Configurata"
statusView.setTextColor(0xFF34d399.toInt())
deviceView.text = deviceName ?: deviceAddr
btnScaleAction.visibility = android.view.View.VISIBLE
btnScaleAction.text = "🔄 Riavvia servizio bilancia"
btnScaleAction.setOnClickListener {
GatewayService.stop(this)
GatewayService.start(this)
Toast.makeText(this, "Servizio bilancia riavviato", Toast.LENGTH_SHORT).show()
}
btnReconfigureScale.visibility = android.view.View.VISIBLE
btnReconfigureScale.setOnClickListener {
GatewayService.stop(this)
prefs.edit()
.remove("scale_device_address")
.remove("scale_device_name")
.putBoolean(KEY_HAS_SCALE, false)
.putBoolean(KEY_SETUP_COMPLETE, false)
.apply()
val intent = Intent(this, SetupActivity::class.java)
intent.putExtra("start_step", 4)
startActivity(intent)
finish()
}
// Probe WebSocket port to show live status
Thread {
val running = try {
java.net.Socket().use { s ->
s.connect(java.net.InetSocketAddress("127.0.0.1", 8765), 1200); true
}
} catch (_: Exception) { false }
runOnUiThread {
if (running) {
statusView.text = "Attivo ✅"
statusView.setTextColor(0xFF34d399.toInt())
deviceView.text = "${deviceName ?: "Bilancia"} — ws://127.0.0.1:8765"
} else {
statusView.text = "Non avviato ⚠️"
statusView.setTextColor(0xFFfbbf24.toInt())
deviceView.text = "${deviceName ?: "Bilancia"} — servizio non in esecuzione"
}
}
}.start()
}
}
// Back
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener { finish() }
// Test connection
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener { testConnection() }
// Run wizard again
findViewById<MaterialButton>(R.id.btnRunWizard).setOnClickListener {
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, false).apply()
startActivity(Intent(this, SetupActivity::class.java))
finish()
}
// Save
findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
val url = urlEdit.text.toString().trim()
if (url.isEmpty()) {
Toast.makeText(this, "URL cannot be empty", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
val screensaverOn = switchScreensaver.isChecked
prefs.edit()
.putString(KEY_URL, url)
.putBoolean(KEY_SCREENSAVER, screensaverOn)
.apply()
// Screen always stays on in kiosk mode — no FLAG_KEEP_SCREEN_ON change needed here.
// Push screensaver preference to the webapp so the in-app clock overlay is toggled.
Thread {
try {
val apiUrl = "$url/api/index.php?action=save_settings"
val body = "{\"screensaver_enabled\":$screensaverOn}"
val conn = (java.net.URL(apiUrl).openConnection() as java.net.HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
connectTimeout = 5000
readTimeout = 5000
doOutput = true
}
conn.outputStream.use { it.write(body.toByteArray()) }
conn.inputStream.close()
conn.disconnect()
} catch (_: Exception) {}
}.start()
Toast.makeText(this, "Impostazioni salvate", Toast.LENGTH_SHORT).show()
finish()
}
}
private fun testConnection() {
val url = urlEdit.text.toString().trim()
if (url.isEmpty()) {
Toast.makeText(this, "Enter a URL first", Toast.LENGTH_SHORT).show()
return
}
Thread {
try {
val conn = URL(url).openConnection()
if (conn is HttpsURLConnection) {
val trustAll = arrayOf<TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
val sc = SSLContext.getInstance("TLS")
sc.init(null, trustAll, java.security.SecureRandom())
conn.sslSocketFactory = sc.socketFactory
conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
}
conn.connectTimeout = 5000
conn.readTimeout = 5000
if (conn is java.net.HttpURLConnection) {
conn.requestMethod = "GET"
val code = conn.responseCode
conn.disconnect()
runOnUiThread {
if (code in 200..399) {
Toast.makeText(this, "✓ Connection successful!", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "⚠ Server responded: $code", Toast.LENGTH_SHORT).show()
}
}
}
} catch (e: Exception) {
runOnUiThread {
Toast.makeText(this, "✗ Cannot reach server", Toast.LENGTH_SHORT).show()
}
}
}.start()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,295 @@
package it.dadaloop.evershelf.kiosk.scale
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 = 20_000L
private const val PREFS_NAME = "evershelf_kiosk"
private const val PREF_SCALE_ADDRESS = "scale_device_address"
private const val PREF_SCALE_NAME = "scale_device_name"
data class BleDeviceInfo(
val device: BluetoothDevice,
val name: String,
val rssi: Int,
val proximity: String,
val scaleScore: Int,
)
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)
}
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
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
fun getSavedDeviceAddress(): String? =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREF_SCALE_ADDRESS, null)
fun getSavedDeviceName(): String? =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREF_SCALE_NAME, null)
fun saveDevice(address: String, name: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(PREF_SCALE_ADDRESS, address)
.putString(PREF_SCALE_NAME, name)
.apply()
}
fun clearSavedDevice() {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.remove(PREF_SCALE_ADDRESS)
.remove(PREF_SCALE_NAME)
.apply()
}
fun enableAutoConnect() {
autoConnectAddress = getSavedDeviceAddress()
}
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
}
}
fun startScan() {
val adapter = bluetoothAdapter ?: run { listener.onError("Bluetooth non disponibile"); return }
if (!adapter.isEnabled) { listener.onError("Bluetooth disabilitato"); return }
if (isScanning) stopScan()
leScanner = adapter.bluetoothLeScanner
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
isScanning = true
try { leScanner?.startScan(null, settings, scanCallback) }
catch (_: Exception) { leScanner?.startScan(scanCallback) }
mainHandler.postDelayed({ stopScan(); listener.onScanStopped() }, SCAN_PERIOD_MS)
}
fun stopScan() {
if (!isScanning) return
isScanning = false
try { leScanner?.stopScan(scanCallback) } catch (_: Exception) {}
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() }
?: try { device.name?.takeIf { it.isNotBlank() } } catch (_: SecurityException) { null }
?: return // skip unnamed devices
val score = scoreLikelyScale(name, result.scanRecord)
val info = BleDeviceInfo(device, name, result.rssi, rssiToProximity(result.rssi), score)
mainHandler.post { listener.onDeviceFound(info) }
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
autoConnectAddress = null
mainHandler.post { connect(device) }
}
}
override fun onScanFailed(errorCode: Int) {
isScanning = false
mainHandler.post { listener.onError("BLE scan failed (code $errorCode)") }
}
}
private fun rssiToProximity(rssi: Int) = when {
rssi >= -60 -> "📶 Vicino"
rssi >= -80 -> "📶 Medio"
else -> "📶 Lontano"
}
private fun scoreLikelyScale(name: String, scanRecord: ScanRecord?): Int {
var score = 0
val lower = name.lowercase()
val foodKeywords = listOf("scale","bilancia","kitchen","food","cucina","coffee","caffe",
"balance","weight","waage","arboleaf","ck10","ck20","ek-","acaia","felicita",
"timemore","brewista","hario","ozeri","etekcity","nutri","nicewell","koios","renpho")
if (foodKeywords.any { lower.contains(it) }) score += 10
val bodyKeywords = listOf("body","fat","bmi","composition","fitness","mi body","lepulse")
if (bodyKeywords.any { lower.contains(it) }) score -= 5
scanRecord?.serviceUuids?.let { uuids ->
val us = uuids.map { it.uuid.toString().lowercase() }
if (us.any { it.startsWith("0000181d") }) score += 15
if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 10
if (us.any { it.startsWith("49535343") }) score += 20
if (us.any { it.startsWith("0000181b") }) score -= 10
}
return score
}
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("Permesso mancante: ${e.message}") }
}
}
fun disconnect() {
pendingSubscriptions.clear()
try { gatt?.disconnect(); gatt?.close() } catch (_: Exception) {}
gatt = null
connectedDeviceName = ""
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
mainHandler.postDelayed({ gatt.discoverServices() }, 500)
}
BluetoothProfile.STATE_DISCONNECTED -> {
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") }
return
}
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)?.let { targetChars.add(it) }
gatt.getService(BleUuids.FFE0)?.getCharacteristic(BleUuids.FFE1)?.let { targetChars.add(it) }
gatt.getService(BleUuids.FFF0)?.let { svc ->
svc.getCharacteristic(BleUuids.FFF4)?.let { targetChars.add(it) }
?: svc.getCharacteristic(BleUuids.FFF1)?.let { targetChars.add(it) }
}
gatt.getService(BleUuids.ACAIA_SERVICE)?.getCharacteristic(BleUuids.ACAIA_CHAR)?.let { targetChars.add(it) }
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("Nessuna caratteristica peso trovata") }
return
}
gatt.getService(BleUuids.BATTERY_SERVICE)?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)?.let { targetChars.add(it) }
try { gatt.device?.address?.let { saveDevice(it, connectedDeviceName) } } catch (_: SecurityException) {}
pendingSubscriptions.clear()
pendingSubscriptions.addAll(targetChars)
val deviceName = try { gatt.device?.name ?: "Scale" } catch (_: SecurityException) { "Scale" }
connectedDeviceName = deviceName
mainHandler.post { listener.onConnected(deviceName) }
subscribeNext(gatt)
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
subscribeNext(gatt)
}
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
processCharacteristicData(characteristic, characteristic.value ?: return)
}
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) }
}
}
}
private fun subscribeNext(gatt: BluetoothGatt) {
val char = pendingSubscriptions.removeFirstOrNull() ?: return
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
try { gatt.readCharacteristic(char) } catch (_: SecurityException) {}
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 { 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 (_: SecurityException) {}
}
private fun processCharacteristicData(char: BluetoothGattCharacteristic, data: ByteArray) {
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR && data.isNotEmpty()) {
val level = data[0].toInt() and 0xFF
mainHandler.post { listener.onBatteryReceived(level) }
return
}
val reading = ScaleProtocol.parse(char, data) { msg -> mainHandler.post { listener.onDebugEvent(msg) } }
if (reading != null && reading.value > 0f) {
mainHandler.post { listener.onWeightReceived(reading) }
}
}
}
@@ -0,0 +1,249 @@
package it.dadaloop.evershelf.kiosk.scale
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import it.dadaloop.evershelf.kiosk.KioskActivity
import it.dadaloop.evershelf.kiosk.R
private const val TAG = "GatewayService"
private const val WS_PORT = 8765
private const val NOTIF_ID = 1001
private const val CHANNEL_ID = "evershelf_gateway"
private const val RECONNECT_DELAY_MS = 8_000L
/**
* Foreground service that keeps the BLE scale connection and WebSocket server alive
* independently of the KioskActivity lifecycle.
*
* The WebSocket server on port 8765 is protocol-compatible with the standalone
* evershelf-scale-gateway app, so the EverShelf webapp JS needs no changes.
*/
class GatewayService : Service(), BleScaleListener, ServerEventListener {
private lateinit var bleManager: BleScaleManager
private var wsServer: GatewayWebSocketServer? = null
private val handler = Handler(Looper.getMainLooper())
private var connectedDeviceName: String? = null
private var batteryLevel: Int? = null
private var reconnectPending = false
companion object {
const val ACTION_START = "evershelf.gateway.START"
const val ACTION_STOP = "evershelf.gateway.STOP"
/** Returns true if the service can try to connect (BLE permissions ok, device saved). */
fun canStart(context: Context): Boolean {
val prefs = context.getSharedPreferences("evershelf_kiosk", Context.MODE_PRIVATE)
val hasScale = prefs.getBoolean("has_scale", false)
val hasDevice = prefs.getString("scale_device_address", null) != null
return hasScale && hasDevice
}
fun start(context: Context) {
val intent = Intent(context, GatewayService::class.java).apply {
action = ACTION_START
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.startService(Intent(context, GatewayService::class.java).apply {
action = ACTION_STOP
})
}
}
override fun onCreate() {
super.onCreate()
bleManager = BleScaleManager(this, this)
createNotificationChannel()
startForeground(NOTIF_ID, buildNotification("Avvio bilancia…"))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
stopSelf()
return START_NOT_STICKY
}
else -> {
startWsServer()
connectToSavedScale()
}
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
bleManager.disconnect()
try { wsServer?.stop(1000) } catch (_: Exception) {}
wsServer = null
super.onDestroy()
}
// ── WebSocket server ──────────────────────────────────────────────────────
private fun startWsServer() {
if (wsServer != null) return
try {
wsServer = GatewayWebSocketServer(WS_PORT, this)
wsServer!!.isReuseAddr = true
wsServer!!.start()
Log.i(TAG, "WebSocket server started on :$WS_PORT")
} catch (e: Exception) {
Log.e(TAG, "Failed to start WebSocket server", e)
updateNotification("⚠️ WebSocket non avviato: ${e.message}")
}
}
// ── BLE connection ────────────────────────────────────────────────────────
private fun connectToSavedScale() {
if (!bleManager.hasRequiredPermissions()) {
updateNotification("⚠️ Permessi Bluetooth mancanti")
return
}
val addr = bleManager.getSavedDeviceAddress() ?: run {
updateNotification("Nessuna bilancia configurata")
return
}
val name = bleManager.getSavedDeviceName() ?: addr
updateNotification("🔍 Connessione a $name")
// Enable auto-connect: the scan callback will connect when the saved device is found
bleManager.enableAutoConnect()
bleManager.startScan()
}
private fun scheduleReconnect() {
if (reconnectPending) return
reconnectPending = true
handler.postDelayed({
reconnectPending = false
if (bleManager.getSavedDeviceAddress() != null) {
updateNotification("🔄 Riconnessione bilancia…")
bleManager.enableAutoConnect()
bleManager.startScan()
}
}, RECONNECT_DELAY_MS)
}
// ── BleScaleListener ─────────────────────────────────────────────────────
override fun onDeviceFound(info: BleDeviceInfo) { /* handled by autoConnect */ }
override fun onConnecting(device: BluetoothDevice) {
val name = try { device.name ?: device.address } catch (_: SecurityException) { device.address }
updateNotification("⏳ Connessione a $name")
}
override fun onConnected(deviceName: String) {
connectedDeviceName = deviceName
updateNotification("$deviceName connessa")
wsServer?.publishStatus("connected", deviceName, batteryLevel)
Log.i(TAG, "BLE scale connected: $deviceName")
}
override fun onDisconnected() {
val name = connectedDeviceName ?: "bilancia"
connectedDeviceName = null
updateNotification("⚠️ $name disconnessa — riconnessione…")
wsServer?.publishStatus("disconnected", null, null)
scheduleReconnect()
}
override fun onWeightReceived(reading: WeightReading) {
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, batteryLevel)
}
override fun onBatteryReceived(level: Int) {
batteryLevel = level
connectedDeviceName?.let { wsServer?.publishStatus("connected", it, level) }
}
override fun onError(message: String) {
Log.w(TAG, "BLE error: $message")
scheduleReconnect()
}
override fun onScanStopped() {
// If not connected yet, schedule a retry so we keep searching after the scale powers on
if (!bleManager.isConnected) scheduleReconnect()
}
override fun onDebugEvent(message: String) {
Log.d(TAG, message)
}
// ── ServerEventListener ───────────────────────────────────────────────────
override fun onClientConnected(address: String) {
Log.d(TAG, "WS client connected: $address")
}
override fun onClientDisconnected(address: String) {
Log.d(TAG, "WS client disconnected: $address")
}
override fun onClientRequestedWeight() { /* weight is pushed via onWeightReceived */ }
// ── Notification ──────────────────────────────────────────────────────────
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"EverShelf Scale Gateway",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Bilancia smart integrata"
setShowBadge(false)
}
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
}
private fun buildNotification(text: String): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, KioskActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
return builder
.setContentTitle("EverShelf Scale")
.setContentText(text)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun updateNotification(text: String) {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIF_ID, buildNotification(text))
}
}
@@ -0,0 +1,98 @@
package it.dadaloop.evershelf.kiosk.scale
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"
interface ServerEventListener {
fun onClientConnected(address: String)
fun onClientDisconnected(address: String)
fun onClientRequestedWeight()
}
/**
* WebSocket server that exposes BLE scale data to EverShelf running in a browser.
* Protocol is identical to the standalone gateway app so the webapp JS needs no changes.
*/
class GatewayWebSocketServer(
port: Int,
private val eventListener: ServerEventListener?,
) : WebSocketServer(InetSocketAddress(port)) {
private val pendingWeightRequests: MutableSet<WebSocket> =
Collections.synchronizedSet(mutableSetOf())
@Volatile private var lastStatusJson: String = buildStatusJson("disconnected", null, null)
@Volatile private var lastWeightJson: String? = null
override fun onStart() {
Log.i(TAG, "WebSocket server started on port ${address.port}")
connectionLostTimeout = 30
}
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
conn.send(lastStatusJson)
lastWeightJson?.let { conn.send(it) }
eventListener?.onClientConnected(conn.remoteSocketAddress?.toString() ?: "?")
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
pendingWeightRequests.remove(conn)
eventListener?.onClientDisconnected(conn.remoteSocketAddress?.toString() ?: "?")
}
override fun onMessage(conn: WebSocket, message: String) {
try {
when (JSONObject(message).optString("type")) {
"ping" -> conn.send("""{"type":"pong"}""")
"get_status" -> conn.send(lastStatusJson)
"get_weight" -> {
pendingWeightRequests.add(conn)
eventListener?.onClientRequestedWeight()
lastWeightJson?.let { conn.send(it) }
}
}
} catch (_: Exception) {}
}
override fun onError(conn: WebSocket?, ex: Exception) {
Log.e(TAG, "WebSocket error", ex)
}
fun publishStatus(state: String, deviceName: String?, battery: Int?) {
lastStatusJson = buildStatusJson(state, deviceName, battery)
broadcast(lastStatusJson)
}
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) { pendingWeightRequests.clear() }
}
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")
obj.put("value", Math.round(value * 10f) / 10.0)
obj.put("unit", unit)
obj.put("stable", stable)
obj.put("timestamp", System.currentTimeMillis())
return obj.toString()
}
}
@@ -0,0 +1,131 @@
package it.dadaloop.evershelf.kiosk.scale
import android.bluetooth.BluetoothGattCharacteristic
import java.util.UUID
// ── Data model ────────────────────────────────────────────────────────────────
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 {
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
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")
val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb")
}
// ── Scale protocol parser ─────────────────────────────────────────────────────
object ScaleProtocol {
private const val MAX_GRAMS = 15000f
private const val MIN_GRAMS = 0.5f
fun resetState() { /* reserved */ }
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
}
when (char.uuid) {
BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug)
}
if (data.size == 18
&& (data[0].toInt() and 0xFF) == 0x10
&& (data[1].toInt() and 0xFF) == 0x12) {
return parseQNFood(data, debug)
}
return parseGeneric(data, debug)
}
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
debug?.invoke("SIG 2A9D: raw=$raw -> ${g}g")
if (g < MIN_GRAMS || g > MAX_GRAMS) null
else WeightReading(g, "g", stable = true)
}
}
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
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")
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"; 0x04 -> "ml"; else -> "g"
}
val value = rawValue / 10f
debug?.invoke("QN-KS: ${value}${unit} stable=$stable")
if (rawValue == 0) return null
val valueG = if (unit == "oz") value * 28.3495f else value
if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null
return WeightReading(value, unit, stable)
}
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) return null
data class C(val pos: Int, val be: Boolean, val div: Float, val label: String)
val candidates = listOf(
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"),
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"),
C(1, false, 2f, "pos1 LE 0.5g"), C(1, true, 2f, "pos1 BE 0.5g"),
C(1, false, 0.1f, "pos1 LE cg"), C(1, true, 0.1f, "pos1 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")
return WeightReading(g, "g", stable = false)
}
}
return null
}
private fun u16le(data: ByteArray, offset: Int) =
(data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
private fun u16be(data: ByteArray, offset: Int) =
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1e293b" />
<corners android:radius="16dp" />
<stroke android:width="1dp" android:color="#334155" />
</shape>
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- House outline -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,24 L26,46 L26,80 L82,80 L82,46 Z" />
<path
android:fillColor="#059669"
android:pathData="M54,28 L30,48 L30,76 L78,76 L78,48 Z" />
<!-- Door -->
<path
android:fillColor="#065F46"
android:pathData="M48,76 L48,58 L60,58 L60,76 Z" />
<!-- Shelves inside -->
<path
android:fillColor="#34D399"
android:pathData="M34,52 L46,52 L46,54 L34,54 Z" />
<path
android:fillColor="#34D399"
android:pathData="M62,52 L74,52 L74,54 L62,54 Z" />
<path
android:fillColor="#34D399"
android:pathData="M34,60 L46,60 L46,62 L34,62 Z" />
<path
android:fillColor="#34D399"
android:pathData="M62,60 L74,60 L74,62 L62,62 Z" />
<!-- Items on shelves (small boxes/jars) -->
<path
android:fillColor="#A7F3D0"
android:pathData="M36,48 L40,48 L40,52 L36,52 Z" />
<path
android:fillColor="#6EE7B7"
android:pathData="M42,49 L45,49 L45,52 L42,52 Z" />
<path
android:fillColor="#A7F3D0"
android:pathData="M64,48 L68,48 L68,52 L64,52 Z" />
<path
android:fillColor="#6EE7B7"
android:pathData="M70,49 L73,49 L73,52 L70,52 Z" />
<path
android:fillColor="#A7F3D0"
android:pathData="M36,56 L40,56 L40,60 L36,60 Z" />
<path
android:fillColor="#6EE7B7"
android:pathData="M42,57 L45,57 L45,60 L42,60 Z" />
<path
android:fillColor="#A7F3D0"
android:pathData="M64,56 L68,56 L68,60 L64,60 Z" />
<path
android:fillColor="#6EE7B7"
android:pathData="M70,57 L73,57 L73,60 L70,60 Z" />
<!-- Chimney -->
<path
android:fillColor="#FFFFFF"
android:pathData="M68,30 L74,30 L74,42 L68,42 Z" />
<path
android:fillColor="#059669"
android:pathData="M69,31 L73,31 L73,42 L69,42 Z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1e293b" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="#334155" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#0f1729" />
<corners android:radius="10dp" />
<stroke android:width="1dp" android:color="#1e293b" />
</shape>
@@ -0,0 +1,155 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#0f172a">
<!-- Splash screen (shown briefly on launch if setup is already done) -->
<LinearLayout
android:id="@+id/splashContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:visibility="visible">
<ImageView
android:layout_width="260dp"
android:layout_height="wrap_content"
android:src="@drawable/ic_logo"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
android:layout_marginBottom="16dp"
android:contentDescription="EverShelf" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="La tua dispensa smart"
android:textColor="#64748b"
android:textSize="16sp"
android:layout_marginBottom="48dp" />
<ProgressBar
android:layout_width="32dp"
android:layout_height="32dp"
android:indeterminateTint="#7c3aed" />
</LinearLayout>
<!-- WebView (shown after setup) -->
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<!-- Settings gear (shown after setup, over WebView) — top-right corner to avoid overlapping modals -->
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="top|end"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:background="@android:color/transparent"
android:src="@android:drawable/ic_menu_manage"
android:alpha="0.12"
android:contentDescription="Settings"
android:scaleType="centerInside"
android:visibility="gone" />
<!-- 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:layout_gravity="top"
android:orientation="vertical"
android:background="#1e293b"
android:visibility="gone">
<!-- Banner row: message + buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:gravity="center_vertical">
<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>
<!-- Thin progress bar at the bottom of the banner -->
<ProgressBar
android:id="@+id/bannerProgressBar"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="4dp"
android:progressTint="#7c3aed"
android:progressBackgroundTint="#334155"
android:max="100"
android:progress="0"
android:indeterminate="false"
android:visibility="gone" />
</LinearLayout>
<!-- Download progress (used by download progress poll) -->
<ProgressBar
android:id="@+id/downloadProgressBar"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="4dp"
android:layout_gravity="top"
android:layout_marginTop="56dp"
android:progressTint="#7c3aed"
android:progressBackgroundTint="#334155"
android:max="100"
android:progress="0"
android:indeterminate="false"
android:visibility="gone" />
<TextView
android:id="@+id/downloadProgressText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|center_horizontal"
android:layout_marginTop="64dp"
android:text=""
android:textColor="#94a3b8"
android:textSize="12sp"
android:visibility="gone" />
</FrameLayout>
@@ -0,0 +1,276 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#0f172a"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="32dp">
<ImageButton
android:id="@+id/btnBack"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/card_background"
android:src="@android:drawable/ic_menu_revert"
android:scaleType="centerInside"
android:contentDescription="Back"
android:layout_marginEnd="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Settings"
android:textColor="#f1f5f9"
android:textSize="22sp"
android:textStyle="bold" />
</LinearLayout>
<!-- Server URL Section -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="SERVER CONNECTION"
android:textColor="#7c3aed"
android:textSize="12sp"
android:textStyle="bold"
android:letterSpacing="0.1"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_background"
android:padding="16dp"
android:layout_marginBottom="24dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="EverShelf URL"
android:textColor="#cbd5e1"
android:textSize="14sp"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/urlEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:textColor="#f1f5f9"
android:textColorHint="#475569"
android:hint="https://192.168.1.100/dispensa"
android:background="@drawable/input_background"
android:padding="14dp"
android:textSize="15sp"
android:singleLine="true"
android:layout_marginBottom="10dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnTestConnection"
android:layout_width="match_parent"
android:layout_height="40dp"
android:text="Test Connection"
android:textSize="13sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#334155"
android:textColor="#94a3b8" />
</LinearLayout>
<!-- Scale Section -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="BILANCIA SMART"
android:textColor="#7c3aed"
android:textSize="12sp"
android:textStyle="bold"
android:letterSpacing="0.1"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_background"
android:padding="16dp"
android:layout_marginBottom="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Servizio bilancia BLE"
android:textColor="#cbd5e1"
android:textSize="14sp" />
<TextView
android:id="@+id/scaleGatewayStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Active"
android:textColor="#34d399"
android:textSize="13sp" />
</LinearLayout>
<TextView
android:id="@+id/scaleDeviceInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="No scale connected"
android:textColor="#64748b"
android:textSize="13sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnConfigureGateway"
android:layout_width="match_parent"
android:layout_height="40dp"
android:text="⚙️ Configura bilancia"
android:textSize="13sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#34d399"
android:textColor="#34d399"
android:layout_marginTop="12dp"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnReconfigureScale"
android:layout_width="match_parent"
android:layout_height="40dp"
android:text="🔄 Riconfigura bilancia"
android:textSize="13sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#fbbf24"
android:textColor="#fbbf24"
android:layout_marginTop="8dp"
android:visibility="gone" />
</LinearLayout>
<!-- Screensaver Section -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="SCHERMO"
android:textColor="#7c3aed"
android:textSize="12sp"
android:textStyle="bold"
android:letterSpacing="0.1"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_background"
android:padding="16dp"
android:layout_marginBottom="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Salvaschermo"
android:textColor="#cbd5e1"
android:textSize="14sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Oscura lo schermo dopo inattività (default: off)"
android:textColor="#64748b"
android:textSize="12sp"
android:layout_marginTop="2dp" />
</LinearLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchScreensaver"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="false" />
</LinearLayout>
</LinearLayout>
<!-- Danger Zone -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="RESET"
android:textColor="#ef4444"
android:textSize="12sp"
android:textStyle="bold"
android:letterSpacing="0.1"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_background"
android:padding="16dp"
android:layout_marginBottom="24dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRunWizard"
android:layout_width="match_parent"
android:layout_height="44dp"
android:text="Run Setup Wizard Again"
android:textSize="14sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#334155"
android:textColor="#94a3b8" />
</LinearLayout>
<!-- Spacer -->
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- Buttons -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="52dp"
android:text="Save Changes"
android:textSize="16sp"
android:textAllCaps="false"
android:backgroundTint="#7c3aed"
android:layout_marginBottom="12dp" />
</LinearLayout>
</ScrollView>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Kiosk</string>
<!-- Setup-Assistent Zeichenfolgen -->
<string name="setup_enter_url">Bitte zuerst eine URL eingeben</string>
<string name="setup_testing">Verbindung wird getestet…</string>
<string name="setup_server_found">EverShelf-Server gefunden und API aktiv!</string>
<string name="setup_api_not_found">Server erreichbar, aber EverShelf-API nicht gefunden. Pfad prüfen.</string>
<string name="setup_unreachable">Server nicht erreichbar</string>
<string name="setup_discover_btn">🔍 Lokales Netzwerk durchsuchen</string> <string name="setup_perms_granted_next">✅ Berechtigungen erteilt — Weiter →</string> <string name="setup_discovering">Suche läuft…</string>
<string name="setup_discovering_detail">Suche nach EverShelf-Servern im lokalen Netzwerk…</string>
<string name="setup_discover_not_found">Kein EverShelf-Server automatisch gefunden. URL manuell eingeben.</string>
<string name="setup_exit_title">Setup beenden?</string>
<string name="setup_exit_message">Die Einrichtung kann später beim erneuten Öffnen der App abgeschlossen werden.</string>
<string name="setup_exit_confirm">Beenden</string>
<string name="setup_exit_cancel">Weiter</string>
<!-- Wizard Schritt 3: Smart-Waage -->
<string name="wizard_step3_title">Smart-Waage (Optional)</string>
<string name="wizard_step3_description">Um eine Bluetooth-Küchenwaage zu verwenden, musst du die EverShelf Scale Gateway App separat installieren.</string>
<string name="wizard_step3_question">Hast du eine Bluetooth-Küchenwaage?</string>
<string name="wizard_step3_yes">✅ Ja, ich habe eine Waage</string>
<string name="wizard_step3_no">➡️ Nein, überspringen</string>
<!-- Gateway-Statusmeldungen -->
<string name="wizard_gateway_installed">Scale Gateway installiert ✅</string>
<string name="wizard_gateway_installed_detail">Wird beim Fortfahren im Hintergrund gestartet.</string>
<string name="wizard_gateway_not_installed">Scale Gateway nicht installiert</string>
<string name="wizard_gateway_not_installed_detail">Installiere die Scale Gateway App, um eine Bluetooth-Waage zu nutzen.</string>
<string name="wizard_gateway_checking">Prüfe auf Updates…</string>
<string name="wizard_gateway_up_to_date">Scale Gateway ist aktuell.</string>
<string name="wizard_gateway_update_available">Update für Scale Gateway verfügbar</string>
<string name="wizard_gateway_update_detail">Tippe auf den Button, um jetzt zu aktualisieren.</string>
<!-- Download- / Installationsfortschritt -->
<string name="install_downloading">Download läuft…</string>
<string name="install_downloading_detail">Bitte warten, die Datei wird heruntergeladen.</string>
<string name="install_installing">Installation läuft…</string>
<string name="install_confirm_detail">Bestätige die Installation im geöffneten Dialog.</string>
<string name="install_success">Erfolgreich installiert!</string>
<string name="install_success_detail">Die App wurde aktualisiert.</string>
<string name="install_error_download">Download fehlgeschlagen</string>
<string name="install_error_download_detail">Verbindung prüfen und erneut versuchen.</string>
<string name="install_error_install">Installation fehlgeschlagen</string>
<string name="install_perm_detail">Aktiviere \'Unbekannte Apps installieren\' in den Einstellungen, dann komm zurück.</string>
<string name="install_btn_retry">↩ Nochmal versuchen</string>
<!-- Schaltflächen -->
<string name="btn_back">Zurück</string>
<string name="btn_launch">🚀 EverShelf starten</string>
<string name="btn_launch_no_scale">🚀 Ohne Waage starten</string>
<string name="btn_download_gateway">📥 Scale Gateway installieren</string>
<string name="btn_update_gateway">📥 Scale Gateway aktualisieren</string>
<!-- Server-Erreichbarkeit prüfen (Wizard Schritt 3) -->
<string name="wizard_server_checking">Server-Verbindung wird geprüft…</string>
<string name="wizard_server_ok">Server erreichbar ✅</string>
<string name="wizard_server_ok_detail">Fehlerberichterstattung aktiv — Installationsfehler werden automatisch an GitHub Issues gesendet.</string>
<string name="wizard_server_error">Server nicht erreichbar ⚠️</string>
<string name="wizard_server_error_detail">Fehler werden GitHub Issues nicht erreichen. URL in Schritt 2 prüfen.</string>
<!-- Bildschirmschoner-Schritt -->
<string name="setup_screensaver_title">Bildschirmschoner</string>
<string name="setup_screensaver_desc">Zeigt nach 5 Minuten Inaktivität eine Uhr mit nützlichen Fakten. Standardmäßig deaktiviert (Bildschirm bleibt immer an).</string>
<string name="setup_screensaver_toggle_label">Bildschirmschoner aktivieren</string>
<string name="setup_screensaver_toggle_hint">Wenn deaktiviert, bleibt der Bildschirm immer an.</string>
<!-- Zusammenfassung -->
<string name="summary_lang">Sprache</string>
<string name="summary_scale_skip">Waage: nicht konfiguriert</string>
<string name="summary_screensaver_on">Bildschirmschoner: aktiv</string>
<string name="summary_screensaver_off">Bildschirm immer an (Bildschirmschoner deaktiviert)</string>
</resources>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Kiosk</string>
<!-- Stringhe setup wizard -->
<string name="setup_enter_url">Inserisci prima un URL</string>
<string name="setup_testing">Verifica connessione…</string>
<string name="setup_server_found">Server EverShelf trovato e API attiva!</string>
<string name="setup_api_not_found">Server raggiungibile ma API EverShelf non trovata. Verifica il percorso.</string>
<string name="setup_unreachable">Impossibile raggiungere il server</string>
<string name="setup_discover_btn">🔍 Cerca nella rete locale</string> <string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string> <string name="setup_discovering">Scansione in corso…</string>
<string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string>
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l\'URL manualmente.</string>
<string name="setup_exit_title">Uscire dalla configurazione?</string>
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l\'app.</string>
<string name="setup_exit_confirm">Esci</string>
<string name="setup_exit_cancel">Continua</string>
<!-- Wizard Step 3: Bilancia smart -->
<string name="wizard_step3_title">Bilancia Smart</string>
<string name="wizard_step3_description">EverShelf Kiosk include un gateway Bluetooth integrato — nessuna app esterna necessaria. Seleziona la tua bilancia qui sotto.</string>
<string name="wizard_step3_question">Hai una bilancia smart Bluetooth?</string>
<string name="wizard_step3_yes">✅ Sì, ho una bilancia</string>
<string name="wizard_step3_no">➡️ No, salta questo passaggio</string>
<!-- Messaggi stato gateway -->
<string name="wizard_gateway_installed">Bilancia salvata ✅</string>
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all\'avvio.</string>
<string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string>
<string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string>
<string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string>
<string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string>
<string name="wizard_gateway_update_available">Bilancia BLE trovata</string>
<string name="wizard_gateway_update_detail">Tocca la bilancia nell\'elenco per connettersi.</string>
<!-- Stati scaricamento / installazione -->
<string name="install_downloading">Scaricamento in corso…</string>
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
<string name="install_installing">Installazione in corso…</string>
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string>
<string name="install_success">Installato con successo!</string>
<string name="install_success_detail">L\'app è stata aggiornata.</string>
<string name="install_error_download">Download fallito</string>
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
<string name="install_error_install">Installazione fallita</string>
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
<string name="install_btn_retry">↩ Riprova</string>
<!-- Pulsanti -->
<string name="btn_back">Indietro</string>
<string name="btn_launch">🚀 Avvia EverShelf</string>
<string name="btn_launch_no_scale">🚀 Avvia senza bilancia</string>
<string name="btn_download_gateway">📥 Installa Scale Gateway</string>
<string name="btn_update_gateway">📥 Aggiorna Scale Gateway</string>
<!-- Verifica raggiungibilità server (step 3 wizard) -->
<string name="wizard_server_checking">Verifica connessione server…</string>
<string name="wizard_server_ok">Server raggiungibile ✅</string>
<string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string>
<string name="wizard_server_error">Server non raggiungibile ⚠️</string>
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l\'URL inserito al passaggio 2.</string>
<!-- Passo salvaschermo -->
<string name="setup_screensaver_title">Salvaschermo</string>
<string name="setup_screensaver_desc">Mostra un orologio con fatti utili dopo 5 minuti di inattività. Di default è disattivato (lo schermo resta sempre acceso).</string>
<string name="setup_screensaver_toggle_label">Attiva salvaschermo</string>
<string name="setup_screensaver_toggle_hint">Se disattivo, lo schermo resta sempre acceso.</string>
<!-- Riepilogo -->
<string name="summary_lang">Lingua</string>
<string name="summary_scale_skip">Bilancia: non configurata</string>
<string name="summary_screensaver_on">Salvaschermo: attivo</string>
<string name="summary_screensaver_off">Schermo sempre acceso (salvaschermo disattivato)</string>
</resources>

Some files were not shown because too many files have changed in this diff Show More