feat: swipe gestures on shopping list items (toggle + delete)

This commit is contained in:
Ulas
2026-03-31 12:49:29 +02:00
parent 33bef8eb3f
commit 0e035af492
2 changed files with 136 additions and 0 deletions
+1
View File
@@ -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 - 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 ### 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 - 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 - 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) - Contacts: vCard export button per contact (downloads .vcf file); vCard import via file input in toolbar (parses FN, TEL, EMAIL, ADR, NOTE, CATEGORIES fields)
+135
View File
@@ -11,6 +11,11 @@ import { stagger, vibrate } from '/utils/ux.js';
// Konstanten // 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 = [ const ITEM_CATEGORIES = [
'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch', 'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch',
'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges', '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) // DOM-Updates (ohne komplettes Re-Render)
// -------------------------------------------------------- // --------------------------------------------------------
@@ -328,6 +462,7 @@ function updateItemsList(container) {
listEl.innerHTML = renderItems(); listEl.innerHTML = renderItems();
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
stagger(listEl.querySelectorAll('.shopping-item')); stagger(listEl.querySelectorAll('.shopping-item'));
wireSwipeGestures(container);
} }
// clear-checked Button aktualisieren // clear-checked Button aktualisieren
const checkedCount = state.items.filter((i) => i.is_checked).length; const checkedCount = state.items.filter((i) => i.is_checked).length;