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 `
+
+
+
+ ${isDone ? 'Öffnen' : 'Erledigt'}
+
+
+
+ Bearbeiten
+
+ ${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
* -------------------------------------------------------- */