feat: Swipe-Gesten für Task-Listenansicht (Mobil)

Swipe links = Status-Toggle (offen ↔ erledigt), Swipe rechts = Bearbeiten-Modal.

- renderSwipeRow(): Wrapper mit zwei Reveal-Panels hinter jeder Karte
- wireSwipeGestures(): Scroll/Swipe-Erkennung via Touch-Events
  · SWIPE_THRESHOLD = 80px für Aktion, frühe Scroll-Erkennung ab 12px vertikal
  · Dämpfung nach Threshold (elastischer Überschuss)
  · Reveal-Panels blenden proportional ein (0 → 1 über Threshold-Weg)
  · Haptic Feedback via navigator.vibrate (40ms links, 20ms rechts)
- tasks.css: .swipe-row, .swipe-reveal--done/--edit, .swipe-row--swiping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ulsklyc
2026-03-24 21:52:15 +01:00
parent 450ae37f42
commit 3903df6445
2 changed files with 202 additions and 1 deletions
+141 -1
View File
@@ -101,6 +101,22 @@ function renderDueDate(dateStr) {
</span>`;
}
function renderSwipeRow(task, innerHtml) {
const isDone = task.status === 'done';
return `
<div class="swipe-row" data-swipe-id="${task.id}" data-swipe-status="${task.status}">
<div class="swipe-reveal swipe-reveal--done" aria-hidden="true">
<i data-lucide="${isDone ? 'rotate-ccw' : 'check'}" style="width:22px;height:22px"></i>
<span>${isDone ? 'Öffnen' : 'Erledigt'}</span>
</div>
<div class="swipe-reveal swipe-reveal--edit" aria-hidden="true">
<i data-lucide="pencil" style="width:22px;height:22px"></i>
<span>Bearbeiten</span>
</div>
${innerHtml}
</div>`;
}
function renderTaskCard(task, opts = {}) {
const { expandedSubtasks = false } = opts;
const isDone = task.status === 'done';
@@ -189,7 +205,7 @@ function renderTaskGroups(tasks, groupMode) {
<span class="task-group__title">${name}</span>
<span class="task-group__count">${groupTasks.length}</span>
</div>
${groupTasks.map((t) => renderTaskCard(t)).join('')}
${groupTasks.map((t) => renderSwipeRow(t, renderTaskCard(t))).join('')}
</div>`).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
// --------------------------------------------------------