From 0e035af49269e9c83e3230bfedd0068c0b6ddce0 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 12:49:29 +0200 Subject: [PATCH] feat: swipe gestures on shopping list items (toggle + delete) --- CHANGELOG.md | 1 + public/pages/shopping.js | 135 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e9e317..3e00b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Mobile: FAB and page-FAB are now hidden when the virtual keyboard is open, preventing them from covering form inputs; detection uses `visualViewport.resize` with a 75% height threshold ### Added +- Shopping: swipe-left to toggle checked/unchecked, swipe-right to delete items on mobile; × delete button hidden on mobile in favour of swipe gesture - Notes: client-side full-text search bar in toolbar — filters by title and content instantly; shows "Keine Treffer" empty state when no match - Dashboard: weather widget refresh button (top-right corner) + automatic 30-minute refresh interval; interval is cleared when navigating away - Contacts: vCard export button per contact (downloads .vcf file); vCard import via file input in toolbar (parses FN, TEL, EMAIL, ADR, NOTE, CATEGORIES fields) diff --git a/public/pages/shopping.js b/public/pages/shopping.js index b0bcef9..88a9aec 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -11,6 +11,11 @@ import { stagger, vibrate } from '/utils/ux.js'; // Konstanten // -------------------------------------------------------- +// Swipe-Gesten Konstanten (identisch zu tasks.js) +const SWIPE_THRESHOLD = 80; // px — Mindestweg für Aktion +const SWIPE_MAX_VERT = 12; // px — vertikaler Toleranzbereich +const SWIPE_LOCK_VERT = 30; // px — ab diesem Weg gilt es als Scroll + const ITEM_CATEGORIES = [ 'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch', 'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges', @@ -318,6 +323,135 @@ function wireQuickAdd(container) { }); } +// -------------------------------------------------------- +// Swipe-Gesten +// -------------------------------------------------------- + +function wireSwipeGestures(container) { + const listEl = container.querySelector('#items-list'); + if (!listEl) return; + + listEl.querySelectorAll('.swipe-row').forEach((row) => { + let startX = 0, startY = 0; + let dx = 0; + let locked = false; // false | 'swipe' | 'scroll' + const card = row.querySelector('.shopping-item'); + if (!card) return; + + function resetCard(animate = true) { + card.style.transition = animate ? 'transform 0.25s ease' : ''; + card.style.transform = ''; + row.classList.remove('swipe-row--swiping'); + row.querySelector('.swipe-reveal--done').style.opacity = '0'; + row.querySelector('.swipe-reveal--delete').style.opacity = '0'; + } + + row.addEventListener('touchstart', (e) => { + if (document.getElementById('shared-modal-overlay')) return; + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + dx = 0; + locked = false; + card.style.transition = ''; + }, { passive: true }); + + row.addEventListener('touchmove', (e) => { + if (locked === 'scroll') return; + + const currentX = e.touches[0].clientX; + const currentY = e.touches[0].clientY; + dx = currentX - startX; + const dy = Math.abs(currentY - startY); + + if (locked === false) { + if (dy > SWIPE_MAX_VERT && Math.abs(dx) < dy) { + locked = 'scroll'; + resetCard(false); + return; + } + if (Math.abs(dx) > SWIPE_MAX_VERT) { + locked = 'swipe'; + } + } + + if (locked !== 'swipe') return; + + if (dy < SWIPE_LOCK_VERT) e.preventDefault(); + + const dampened = dx > 0 + ? Math.min(dx, SWIPE_THRESHOLD + (dx - SWIPE_THRESHOLD) * 0.2) + : Math.max(dx, -(SWIPE_THRESHOLD + (-dx - SWIPE_THRESHOLD) * 0.2)); + + card.style.transform = `translateX(${dampened}px)`; + row.classList.add('swipe-row--swiping'); + + const progress = Math.min(Math.abs(dx) / SWIPE_THRESHOLD, 1); + if (dx < 0) { + row.querySelector('.swipe-reveal--done').style.opacity = String(progress); + row.querySelector('.swipe-reveal--delete').style.opacity = '0'; + } else { + row.querySelector('.swipe-reveal--delete').style.opacity = String(progress); + row.querySelector('.swipe-reveal--done').style.opacity = '0'; + } + }, { passive: false }); + + row.addEventListener('touchend', async () => { + if (locked !== 'swipe') { resetCard(false); return; } + + const itemId = Number(row.dataset.swipeId); + const checked = Number(row.dataset.swipeChecked); + + if (dx < -SWIPE_THRESHOLD) { + // Swipe links → abhaken / zurück + card.style.transition = 'transform 0.2s ease'; + card.style.transform = 'translateX(-110%)'; + vibrate(40); + setTimeout(async () => { + resetCard(false); + const newVal = checked ? 0 : 1; + const item = state.items.find((i) => i.id === itemId); + if (item) { + item.is_checked = newVal; + updateItemsList(container); + updateListCounter(state.activeListId, 0, newVal ? 1 : -1); + renderTabs(container); + } + try { + await api.patch(`/shopping/items/${itemId}`, { is_checked: newVal }); + vibrate(10); + } catch (err) { + if (item) item.is_checked = checked; + updateItemsList(container); + window.oikos.showToast(err.message, 'danger'); + } + }, 200); + + } else if (dx > SWIPE_THRESHOLD) { + // Swipe rechts → löschen + card.style.transition = 'transform 0.2s ease'; + card.style.transform = 'translateX(110%)'; + vibrate(40); + setTimeout(async () => { + const item = state.items.find((i) => i.id === itemId); + try { + await api.delete(`/shopping/items/${itemId}`); + state.items = state.items.filter((i) => i.id !== itemId); + updateItemsList(container); + updateListCounter(state.activeListId, -1, item?.is_checked ? -1 : 0); + renderTabs(container); + } catch (err) { + resetCard(true); + window.oikos.showToast(err.message, 'danger'); + } + }, 200); + + } else { + resetCard(true); + } + }); + }); +} + // -------------------------------------------------------- // DOM-Updates (ohne komplettes Re-Render) // -------------------------------------------------------- @@ -328,6 +462,7 @@ function updateItemsList(container) { listEl.innerHTML = renderItems(); if (window.lucide) window.lucide.createIcons(); stagger(listEl.querySelectorAll('.shopping-item')); + wireSwipeGestures(container); } // clear-checked Button aktualisieren const checkedCount = state.items.filter((i) => i.is_checked).length;