/** * GoingsOn - Mobile Interaction Wiring * Connects touch.js gesture utilities to list views using event delegation. * Handles swipe-to-action, pull-to-refresh, and long-press selection. * All no-ops on non-touch devices. */ (function() { 'use strict'; if (!GoingsOn.touch?.isTouchDevice) return; // ============ Swipe Delegation ============ /** * Add delegated swipe handling to a container. Tracks the swiped row * internally so virtual-scroller DOM recycling doesn't cause issues. * * @param {HTMLElement} container - Scrollable container (e.g. #task-list-container) * @param {Object} config * @param {string} config.rowSelector - CSS selector for swipeable rows * @param {Function} config.getActions - (rowEl) => { left?: { action }, right?: { action } } | null * @param {number} [config.threshold=80] - Pixels to trigger action */ function addSwipeDelegate(container, config) { const threshold = config.threshold || 80; let activeRow = null; let startX = 0; let startY = 0; let currentX = 0; let isDragging = false; let isHorizontal = null; let actions = null; // Phase 7 Tier 3 #11 — peek-label affordance. As the user drags past // the threshold, surface the action that will fire ("Complete" / // "Snooze" / "Archive" / "Delete") so the gesture is discoverable. function attachPeekLabels(row, actions) { if (actions.right?.label) { const peek = document.createElement('div'); peek.className = 'swipe-peek swipe-peek--right' + (actions.right.kind ? ' swipe-peek--' + actions.right.kind : ''); peek.textContent = actions.right.label; row.appendChild(peek); } if (actions.left?.label) { const peek = document.createElement('div'); peek.className = 'swipe-peek swipe-peek--left' + (actions.left.kind ? ' swipe-peek--' + actions.left.kind : ''); peek.textContent = actions.left.label; row.appendChild(peek); } } function updatePeek(row, currentX, threshold) { const ratio = Math.min(1, Math.abs(currentX) / threshold); const peek = row.querySelector(currentX >= 0 ? '.swipe-peek--right' : '.swipe-peek--left'); const otherPeek = row.querySelector(currentX >= 0 ? '.swipe-peek--left' : '.swipe-peek--right'); if (peek) { peek.style.opacity = String(ratio); peek.classList.toggle('swipe-peek--ready', Math.abs(currentX) >= threshold); } if (otherPeek) { otherPeek.style.opacity = '0'; otherPeek.classList.remove('swipe-peek--ready'); } } function clearPeek(row) { row.querySelectorAll('.swipe-peek').forEach(el => el.remove()); } container.addEventListener('touchstart', function(e) { const row = e.target.closest(config.rowSelector); if (!row) return; actions = config.getActions(row); if (!actions) return; activeRow = row; const touch = e.touches[0]; startX = touch.clientX; startY = touch.clientY; currentX = 0; isDragging = true; isHorizontal = null; activeRow.style.transition = 'none'; attachPeekLabels(activeRow, actions); }, { passive: true }); container.addEventListener('touchmove', function(e) { if (!isDragging || !activeRow) return; const touch = e.touches[0]; const dx = touch.clientX - startX; const dy = touch.clientY - startY; // Determine direction on first significant move if (isHorizontal === null) { if (Math.abs(dx) > 10 || Math.abs(dy) > 10) { isHorizontal = Math.abs(dx) > Math.abs(dy); } if (!isHorizontal) return; } if (!isHorizontal) return; e.preventDefault(); currentX = dx; // Clamp to allowed directions if (dx < 0 && !actions.left) currentX = 0; if (dx > 0 && !actions.right) currentX = 0; // Rubber-band past threshold const maxSwipe = threshold * 1.5; if (Math.abs(currentX) > threshold) { const overshoot = Math.abs(currentX) - threshold; const dampened = threshold + overshoot * 0.3; currentX = currentX > 0 ? Math.min(dampened, maxSwipe) : Math.max(-dampened, -maxSwipe); } activeRow.style.transform = `translateX(${currentX}px)`; updatePeek(activeRow, currentX, threshold); }, { passive: false }); function onEnd() { if (!isDragging || !activeRow) return; isDragging = false; activeRow.style.transition = 'transform 0.2s ease'; if (Math.abs(currentX) >= threshold) { if (currentX < 0 && actions?.left?.action) { actions.left.action(); } else if (currentX > 0 && actions?.right?.action) { actions.right.action(); } } activeRow.style.transform = 'translateX(0)'; const row = activeRow; setTimeout(() => clearPeek(row), 200); activeRow = null; actions = null; currentX = 0; isHorizontal = null; } container.addEventListener('touchend', onEnd, { passive: true }); container.addEventListener('touchcancel', onEnd, { passive: true }); } // ============ Long-Press Delegation ============ /** * Add delegated long-press handling to a container for selection mode. * @param {HTMLElement} container * @param {string} rowSelector - CSS selector for pressable rows * @param {Function} onLongPress - (rowEl) => void */ function addLongPressDelegate(container, rowSelector, onLongPress) { let timer = null; let startX = 0; let startY = 0; let activeRow = null; const MOVE_THRESHOLD = 10; const DURATION = 500; container.addEventListener('touchstart', function(e) { const row = e.target.closest(rowSelector); if (!row) return; activeRow = row; const touch = e.touches[0]; startX = touch.clientX; startY = touch.clientY; timer = setTimeout(() => { timer = null; // Prevent subsequent click activeRow.addEventListener('click', function prevent(ev) { ev.preventDefault(); ev.stopPropagation(); }, { once: true, capture: true }); onLongPress(activeRow); }, DURATION); }, { passive: true }); container.addEventListener('touchmove', function(e) { if (!timer) return; const touch = e.touches[0]; if (Math.abs(touch.clientX - startX) > MOVE_THRESHOLD || Math.abs(touch.clientY - startY) > MOVE_THRESHOLD) { clearTimeout(timer); timer = null; } }, { passive: true }); function cancel() { if (timer) { clearTimeout(timer); timer = null; } activeRow = null; } container.addEventListener('touchend', cancel, { passive: true }); container.addEventListener('touchcancel', cancel, { passive: true }); } // ============ Wire Everything on Init ============ function init() { wireTaskSwipe(); wireEmailSwipe(); wireEventSwipe(); wirePullToRefresh(); wirePullToRefreshReviews(); wireTimeViewSwipe(); wireLongPress(); wireKeyboardScrollIntoView(); } // ============ Keyboard Avoidance ============ // When the virtual keyboard opens on mobile, focused inputs near the // bottom of the screen get obscured. visualViewport reports the visible // area after keyboard inset; if the focused element overlaps that bottom // edge, scroll it into view. function wireKeyboardScrollIntoView() { const vv = window.visualViewport; if (!vv) return; function maybeScrollFocused() { const el = document.activeElement; if (!el) return; const tag = el.tagName; if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !el.isContentEditable) return; // visualViewport.height shrinks when keyboard is open. const rect = el.getBoundingClientRect(); const visibleBottom = vv.height + vv.offsetTop; const margin = 24; if (rect.bottom > visibleBottom - margin) { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } } document.addEventListener('focusin', () => { // Defer so visualViewport has updated after keyboard animates in. setTimeout(maybeScrollFocused, 250); }); vv.addEventListener('resize', maybeScrollFocused); } // --- Tasks --- function wireTaskSwipe() { const container = document.getElementById('task-list-container'); if (!container) return; addSwipeDelegate(container, { rowSelector: '.task-row', getActions: (row) => { const id = row.dataset.id; if (!id) return null; return { right: { label: 'Complete', kind: 'success', action: () => GoingsOn.tasks.complete(id) }, left: { label: 'Snooze', kind: 'warn', action: () => GoingsOn.snooze.openModal('task', id) }, }; }, }); } // --- Emails --- function wireEmailSwipe() { const container = document.getElementById('email-list'); if (!container) return; addSwipeDelegate(container, { rowSelector: '.email-item', getActions: (row) => { const id = row.dataset.id; if (!id) return null; return { right: { label: 'Archive', kind: 'success', action: () => GoingsOn.emails.archive(id) }, left: { label: 'Delete', kind: 'danger', action: () => GoingsOn.emails.delete(id) }, }; }, }); } // --- Events --- function wireEventSwipe() { const upcomingContainer = document.getElementById('event-list-container'); const pastContainer = document.getElementById('past-event-list-container'); function getEventActions(row) { const id = row.dataset.id; if (!id) return null; return { left: { label: 'Delete', kind: 'danger', action: () => GoingsOn.events.delete(id) }, }; } if (upcomingContainer) { addSwipeDelegate(upcomingContainer, { rowSelector: '.event-row-virtual', getActions: getEventActions, }); } if (pastContainer) { addSwipeDelegate(pastContainer, { rowSelector: '.event-row-virtual', getActions: getEventActions, }); } } // --- Pull to Refresh --- function wirePullToRefresh() { const taskContainer = document.getElementById('task-list-container'); const emailContainer = document.getElementById('email-list'); const eventContainer = document.getElementById('event-list-container'); if (taskContainer) { GoingsOn.touch.addPullToRefresh(taskContainer, () => GoingsOn.tasks.load()); } if (emailContainer) { GoingsOn.touch.addPullToRefresh(emailContainer, () => GoingsOn.emails.load()); } if (eventContainer) { GoingsOn.touch.addPullToRefresh(eventContainer, () => GoingsOn.events.load()); } } // --- Long-Press Selection --- /** * Toggle an item's selection state by directly manipulating the * SelectionManager's selectedIds set + syncing the visible checkbox. */ function toggleSelectionById(selectionManager, id) { if (selectionManager.selectedIds.has(id)) { selectionManager.selectedIds.delete(id); } else { selectionManager.selectedIds.add(id); } selectionManager._syncVisibleCheckboxes(); selectionManager.updateBulkActionsBar(); } function wireLongPress() { const taskContainer = document.getElementById('task-list-container'); const emailContainer = document.getElementById('email-list'); if (taskContainer) { addLongPressDelegate(taskContainer, '.task-row', (row) => { const id = row.dataset.id; if (id) toggleSelectionById(GoingsOn.tasks.selection, id); }); } if (emailContainer) { addLongPressDelegate(emailContainer, '.email-item', (row) => { const id = row.dataset.id; if (id) toggleSelectionById(GoingsOn.emails.selection, id); }); } } // --- Pull to Refresh: Reviews --- function wirePullToRefreshReviews() { const monthlyContainer = document.getElementById('monthly-review-content'); const weeklyContainer = document.getElementById('weekly-review-content'); if (monthlyContainer) { GoingsOn.touch.addPullToRefresh(monthlyContainer, () => GoingsOn.monthlyReview.load()); } if (weeklyContainer) { GoingsOn.touch.addPullToRefresh(weeklyContainer, () => GoingsOn.weeklyReview.load()); } } // --- Swipe Nav: Day/Week/Month --- function wireTimeViewSwipe() { const timeView = document.getElementById('time-view'); if (!timeView) return; const views = ['day-plan', 'weekly-review', 'monthly-review']; function getCurrentIndex() { const current = GoingsOn.state.currentView; const idx = views.indexOf(current); return idx >= 0 ? idx : 0; } let skipSwipe = false; timeView.addEventListener('touchstart', (e) => { // Skip swipe if touch started inside day-plan timeline (has its own day swipe) skipSwipe = !!e.target.closest('#timeline-container'); }, { passive: true }); GoingsOn.touch.addSwipeNavigation(timeView, { onLeft: () => { if (skipSwipe) return; const idx = getCurrentIndex(); if (idx < views.length - 1) { GoingsOn.navigation.switchView(views[idx + 1]); } }, onRight: () => { if (skipSwipe) return; const idx = getCurrentIndex(); if (idx > 0) { GoingsOn.navigation.switchView(views[idx - 1]); } }, }); } // ============ Populate Namespace ============ GoingsOn.mobile = { init }; // Initialize after DOM is ready. app.js calls init() which loads views, // so we listen for the same event or defer to next tick. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { // DOM already ready — defer to let domain modules register first setTimeout(init, 0); } })();