diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 5dd0359..d9e4d56 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -101,6 +101,22 @@ function renderDueDate(dateStr) { `; } +function renderSwipeRow(task, innerHtml) { + const isDone = task.status === 'done'; + return ` +
+ + + ${innerHtml} +
`; +} + function renderTaskCard(task, opts = {}) { const { expandedSubtasks = false } = opts; const isDone = task.status === 'done'; @@ -189,7 +205,7 @@ function renderTaskGroups(tasks, groupMode) { ${name} ${groupTasks.length} - ${groupTasks.map((t) => renderTaskCard(t)).join('')} + ${groupTasks.map((t) => renderSwipeRow(t, renderTaskCard(t))).join('')} `).join(''); } @@ -601,6 +617,7 @@ function renderTaskList(container) { listEl.innerHTML = renderTaskGroups(state.tasks, state.groupMode); if (window.lucide) window.lucide.createIcons(); updateOverdueBadge(); + wireSwipeGestures(container); } function renderFilters(container) { @@ -663,6 +680,129 @@ function updateOverdueBadge() { } } +// -------------------------------------------------------- +// Swipe-Gesten (Mobil: links = erledigt, rechts = bearbeiten) +// -------------------------------------------------------- + +const SWIPE_THRESHOLD = 80; // px — Mindestweg für Aktion +const SWIPE_MAX_VERT = 12; // px — vertikaler Bewegungs-Toleranzbereich (darunter: kein Scroll-Abbruch) +const SWIPE_LOCK_VERT = 30; // px — ab diesem Weg gilt es als Scroll (Swipe abgebrochen) + +function wireSwipeGestures(container) { + const listEl = container.querySelector('#task-list'); + if (!listEl) return; + + listEl.querySelectorAll('.swipe-row').forEach((row) => { + let startX = 0, startY = 0; + let dx = 0; + let locked = false; // false = unentschieden, 'swipe' | 'scroll' + const card = row.querySelector('.task-card'); + if (!card) return; + + function resetCard(animate = true) { + card.style.transition = animate ? 'transform 0.25s ease' : ''; + card.style.transform = ''; + row.classList.remove('swipe-row--swiping'); + // Reveal-Panels zurücksetzen + row.querySelector('.swipe-reveal--done').style.opacity = '0'; + row.querySelector('.swipe-reveal--edit').style.opacity = '0'; + } + + row.addEventListener('touchstart', (e) => { + // Geste ignorieren wenn Modal offen + if (document.getElementById('task-modal-backdrop')) 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); + + // Scroll-Richtung früh erkennen + 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; + + // Vertikalen Scroll verhindern sobald Swipe erkannt + if (dy < SWIPE_LOCK_VERT) e.preventDefault(); + + // Karte verschieben (gedämpft nach THRESHOLD) + 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'); + + // Reveal-Panels einblenden (0 → 1 über Threshold) + 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--edit').style.opacity = '0'; + } else { + row.querySelector('.swipe-reveal--edit').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 taskId = row.dataset.swipeId; + const status = row.dataset.swipeStatus; + + if (dx < -SWIPE_THRESHOLD) { + // Swipe links → Status-Toggle (offen ↔ erledigt) + card.style.transition = 'transform 0.2s ease'; + card.style.transform = 'translateX(-110%)'; + if (navigator.vibrate) navigator.vibrate(40); + setTimeout(async () => { + resetCard(false); + try { + await toggleTaskStatus(taskId, status); + await loadTasks(container); + } catch (err) { + window.oikos.showToast(err.message, 'danger'); + await loadTasks(container); + } + }, 200); + + } else if (dx > SWIPE_THRESHOLD) { + // Swipe rechts → Bearbeiten-Modal + resetCard(true); + if (navigator.vibrate) navigator.vibrate(20); + try { + const task = await loadTaskForEdit(taskId); + openModal(renderModal({ task, users: state.users })); + wireModalEvents(container); + } catch (err) { + window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger'); + } + + } else { + resetCard(true); + } + }, { passive: true }); + }); +} + // -------------------------------------------------------- // Event-Verdrahtung // -------------------------------------------------------- diff --git a/public/styles/tasks.css b/public/styles/tasks.css index b0af895..171105b 100644 --- a/public/styles/tasks.css +++ b/public/styles/tasks.css @@ -153,6 +153,67 @@ border-radius: var(--radius-full); } +/* -------------------------------------------------------- + * Swipe-Wrapper (Mobil-Gesten) + * -------------------------------------------------------- */ +.swipe-row { + position: relative; + overflow: hidden; + border-radius: var(--radius-md); + margin-bottom: var(--space-2); + /* Verhindert ungewolltes Flackern auf iOS */ + -webkit-backface-visibility: hidden; +} + +/* Kein Margin mehr am Task-Card selbst (übernimmt swipe-row) */ +.swipe-row .task-card { + margin-bottom: 0; + border-radius: var(--radius-md); + position: relative; + z-index: 1; + will-change: transform; +} + +/* Reveal-Panels hinter der Karte */ +.swipe-reveal { + position: absolute; + top: 0; + bottom: 0; + width: 50%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-1); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + opacity: 0; + pointer-events: none; + z-index: 0; + transition: opacity 0.05s linear; +} + +/* Links hinter der Karte = Erledigt (Swipe nach links) */ +.swipe-reveal--done { + right: 0; + background-color: var(--color-success); + color: #fff; + border-radius: 0 var(--radius-md) var(--radius-md) 0; +} + +/* Rechts hinter der Karte = Bearbeiten (Swipe nach rechts) */ +.swipe-reveal--edit { + left: 0; + background-color: var(--color-accent); + color: #fff; + border-radius: var(--radius-md) 0 0 var(--radius-md); +} + +/* Touch-Feedback: leichte Hervorhebung während Swipe */ +.swipe-row--swiping .task-card { + box-shadow: var(--shadow-lg); +} + /* -------------------------------------------------------- * Task-Card * -------------------------------------------------------- */