diff --git a/src/views/Chores/CompactChoreCard.jsx b/src/views/Chores/CompactChoreCard.jsx index 016068d..24e130e 100644 --- a/src/views/Chores/CompactChoreCard.jsx +++ b/src/views/Chores/CompactChoreCard.jsx @@ -1,7 +1,12 @@ import { CancelScheduleSend, Check, + Delete, + Edit, + Pause, + PlayArrow, Repeat, + Schedule, TimesOneMobiledata, Webhook, } from '@mui/icons-material' @@ -29,6 +34,8 @@ import { import { DeleteChore, MarkChoreComplete, + PauseChore, + StartChore, UpdateChoreAssignee, UpdateDueDate, } from '../../utils/Fetcher' @@ -73,6 +80,196 @@ const CompactChoreCard = ({ const { showError } = useNotification() + // Swipe functionality state + const [swipeTranslateX, setSwipeTranslateX] = React.useState(0) + const [isDragging, setIsDragging] = React.useState(false) + const [isSwipeRevealed, setIsSwipeRevealed] = React.useState(false) + const [hoverTimer, setHoverTimer] = React.useState(null) + const [isTouchDevice, setIsTouchDevice] = React.useState(false) + const swipeThreshold = 80 // Minimum swipe distance to reveal actions + const maxSwipeDistance = 220 // Maximum swipe distance + const dragStartX = React.useRef(0) + const cardRef = React.useRef(null) + + // Detect if device supports touch + React.useEffect(() => { + const checkTouchDevice = () => { + setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0) + } + checkTouchDevice() + }, []) + + // Swipe gesture handlers + const handleTouchStart = e => { + if (isMultiSelectMode || viewOnly) return + + dragStartX.current = e.touches[0].clientX + setIsDragging(true) + } + + const handleTouchMove = e => { + if (isMultiSelectMode || viewOnly || !isDragging) return + + const currentX = e.touches[0].clientX + const deltaX = currentX - dragStartX.current + + if (isSwipeRevealed) { + // When actions are revealed, allow right swipe to hide + if (deltaX > 0) { + const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) + setSwipeTranslateX(clampedDelta) + } + } else { + // When actions are hidden, allow left swipe to reveal + if (deltaX < 0) { + const clampedDelta = Math.max(deltaX, -maxSwipeDistance) + setSwipeTranslateX(clampedDelta) + } + } + } + + const handleTouchEnd = () => { + if (isMultiSelectMode || viewOnly || !isDragging) return + + setIsDragging(false) + + if (isSwipeRevealed) { + // When actions are revealed, check if user swiped right enough to hide + if (swipeTranslateX > -swipeThreshold) { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } else { + // Snap back to revealed position + setSwipeTranslateX(-maxSwipeDistance) + } + } else { + // When actions are hidden, check if user swiped left enough to reveal + if (Math.abs(swipeTranslateX) > swipeThreshold) { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + } else { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } + } + } + + const handleMouseDown = e => { + if (isMultiSelectMode || viewOnly) return + + dragStartX.current = e.clientX + setIsDragging(true) + } + + const handleMouseMove = e => { + if (isMultiSelectMode || viewOnly || !isDragging) return + + const currentX = e.clientX + const deltaX = currentX - dragStartX.current + + if (isSwipeRevealed) { + // When actions are revealed, allow right swipe to hide + if (deltaX > 0) { + const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) + setSwipeTranslateX(clampedDelta) + } + } else { + // When actions are hidden, allow left swipe to reveal + if (deltaX < 0) { + const clampedDelta = Math.max(deltaX, -maxSwipeDistance) + setSwipeTranslateX(clampedDelta) + } + } + } + + const handleMouseUp = () => { + if (isMultiSelectMode || viewOnly || !isDragging) return + + setIsDragging(false) + + if (isSwipeRevealed) { + // When actions are revealed, check if user swiped right enough to hide + if (swipeTranslateX > -swipeThreshold) { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } else { + // Snap back to revealed position + setSwipeTranslateX(-maxSwipeDistance) + } + } else { + // When actions are hidden, check if user swiped left enough to reveal + if (Math.abs(swipeTranslateX) > swipeThreshold) { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + } else { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } + } + } + + const resetSwipe = () => { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } + + // Hover functionality for desktop + const handleMouseEnter = () => { + if (isMultiSelectMode || viewOnly || isSwipeRevealed || isTouchDevice) + return + const timer = setTimeout(() => { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + setHoverTimer(null) + }, 1500) + setHoverTimer(timer) + } + + const handleMouseLeave = () => { + if (isTouchDevice) return + + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } + + // Add a small delay before hiding to allow moving to action area + if (isSwipeRevealed) { + const hideTimer = setTimeout(() => { + resetSwipe() + }, 300) + setHoverTimer(hideTimer) + } + } + + const handleActionAreaMouseEnter = () => { + if (isTouchDevice) return + + // Clear any pending timer when entering action area (both show and hide timers) + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } + } + + const handleActionAreaMouseLeave = () => { + if (isTouchDevice) return + + // Hide immediately when leaving action area + if (isSwipeRevealed) { + resetSwipe() + } + } + + // Clean up timer on unmount + React.useEffect(() => { + return () => { + if (hoverTimer) { + clearTimeout(hoverTimer) + } + } + }, [hoverTimer]) + // All the existing handler methods (same as original ChoreCard) const handleDelete = () => { setConfirmModelConfig({ @@ -385,337 +582,519 @@ const CompactChoreCard = ({ return TASK_COLOR.NO_PRIORITY } } + const handleChorePause = () => { + PauseChore(chore.id).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = { + ...chore, + ...data.res, + } + onChoreUpdate(newChore, 'paused') + }) + } + }) + } + const handleChoreStart = () => { + StartChore(chore.id).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = { + ...chore, + ...data.res, + } + onChoreUpdate(newChore, 'started') + }) + } + }) + } return ( + {/* Action buttons underneath (revealed on swipe) */} + { - if (isMultiSelectMode) { - onSelectionToggle() - } else { - navigate(`/chores/${chore.id}`) - } - }} - > - {/* Priority bar clickable area */} - {chore.priority > 0 && ( - + { + e.stopPropagation() + resetSwipe() + + if (chore.status === 0 || chore.status === 2) { + handleChoreStart() + } else { + // handleChorePause() + handleTaskCompletion() + } + }} sx={{ + width: 40, + height: 40, + mx: 1, + // bgcolor: 'success.100', + // color: 'success.600', + // '&:hover': { + // bgcolor: 'success.200', + // }, + }} + > + {chore.status !== 1 ? ( + + ) : ( + + )} + + + { + e.stopPropagation() + resetSwipe() + setIsChangeDueDateModalOpen(true) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + // bgcolor: 'warning.100', + // color: 'warning.600', + // '&:hover': { + // bgcolor: 'warning.200', + // }, + }} + > + + + + { + e.stopPropagation() + resetSwipe() + navigate(`/chores/${chore.id}/edit`) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + // bgcolor: 'neutral.100', + // color: 'neutral.600', + // '&:hover': { + // bgcolor: 'neutral.200', + // }, + }} + > + + + + { + e.stopPropagation() + resetSwipe() + handleDelete() + }} + sx={{ + width: 40, + height: 40, + mx: 1, + }} + > + + + + + {/* Main card content */} + { - e.stopPropagation() - onChipClick({ priority: chore.priority }) - }} - /> - )} - - {/* Animated transition container for Complete Button / Multi-select checkbox */} - { + if (isSwipeRevealed) { + resetSwipe() + return + } + if (isMultiSelectMode) { + onSelectionToggle() + } else { + navigate(`/chores/${chore.id}`) + } + }} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + // onMouseEnter={handleMouseEnter} > - {/* Complete Button */} + {/* Priority bar clickable area */} + {chore.priority > 0 && ( + { + e.stopPropagation() + onChipClick({ priority: chore.priority }) + }} + /> + )} + + {/* Animated transition container for Complete Button / Multi-select checkbox */} + {/* Complete Button */} + + { + e.stopPropagation() + if (chore.status === 0) { + handleTaskCompletion() + } else if (chore.status === 1) { + handleChorePause() + } else { + handleChoreStart() + } + }} + disabled={isPendingCompletion || notInCompletionWindow(chore)} + sx={{ + width: 32, + height: 32, + borderRadius: '50%', + transition: 'all 0.2s ease', + '&:hover': { + transform: 'scale(1.05)', + }, + + '&:active': { + transform: 'scale(0.95)', + }, + '&:disabled': { + opacity: 0.5, + transform: 'none', + }, + }} + > + {isPendingCompletion ? ( + + ) : chore.status === 0 ? ( + + ) : chore.status === 1 ? ( + + ) : ( + + )} + + + + {/* Multi-select Checkbox */} + + e.stopPropagation()} + /> + + + + {/* Content - Center */} + + {/* Line 1: Name + Due Date */} + + {/* Chore Name */} + + {chore.name} + + + {/* Due Date - Inline with name */} + + {getDueDateText(chore.nextDueDate)} + + + + {/* Line 2: Metadata */} + + {getFrequencyIcon(chore)} + + {formatMetadata()} + + + {/* Labels - Priority chip removed, now shown as vertical bar */} + {chore.labelsV2?.map(l => ( +
{ + e.stopPropagation() + onChipClick({ label: l }) + }} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation() + onChipClick({ label: l }) + } + }} + style={{ + cursor: 'pointer', + padding: 0, + margin: 0, + display: 'flex', + alignItems: 'center', + }} + key={`compact-chorecard-${chore.id}-label-${l.id}`} + > + + {l?.name} + +
+ ))} +
+
+ + {/* Right side - Action Menu with animation */} + - { - e.stopPropagation() - handleTaskCompletion() - }} - disabled={isPendingCompletion || notInCompletionWindow(chore)} + setIsCompleteWithNoteModalOpen(true)} + onCompleteWithPastDate={() => + setIsCompleteWithPastDateModalOpen(true) + } + onChangeAssignee={() => setIsChangeAssigneeModalOpen(true)} + onChangeDueDate={() => setIsChangeDueDateModalOpen(true)} + onWriteNFC={() => setIsNFCModalOpen(true)} + onDelete={handleDelete} + onMouseEnter={handleMouseEnter} + // onMouseLeave={handleMouseLeave} sx={{ width: 32, height: 32, - borderRadius: '50%', - transition: 'all 0.2s ease', - '&:hover': { - transform: 'scale(1.05)', - }, - - '&:active': { - transform: 'scale(0.95)', - }, - '&:disabled': { - opacity: 0.5, - transform: 'none', - }, - }} - > - {isPendingCompletion ? ( - - ) : ( - - )} - - - - {/* Multi-select Checkbox */} - - e.stopPropagation()} + onOpen={() => { + handleMouseLeave() + }} />
- - {/* Content - Center */} - - {/* Line 1: Name + Due Date */} - - {/* Chore Name */} - - {chore.name} - - - {/* Due Date - Inline with name */} - - {getDueDateText(chore.nextDueDate)} - - - - {/* Line 2: Metadata */} - - {getFrequencyIcon(chore)} - - {formatMetadata()} - - - {/* Labels - Priority chip removed, now shown as vertical bar */} - {chore.labelsV2?.map(l => ( -
{ - e.stopPropagation() - onChipClick({ label: l }) - }} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation() - onChipClick({ label: l }) - } - }} - style={{ - cursor: 'pointer', - padding: 0, - margin: 0, - display: 'flex', - alignItems: 'center', - }} - key={`compact-chorecard-${chore.id}-label-${l.id}`} - > - - {l?.name} - -
- ))} -
-
- - {/* Right side - Action Menu with animation */} - - setIsCompleteWithNoteModalOpen(true)} - onCompleteWithPastDate={() => - setIsCompleteWithPastDateModalOpen(true) - } - onChangeAssignee={() => setIsChangeAssigneeModalOpen(true)} - onChangeDueDate={() => setIsChangeDueDateModalOpen(true)} - onWriteNFC={() => setIsNFCModalOpen(true)} - onDelete={handleDelete} - sx={{ - width: 32, - height: 32, - color: 'text.tertiary', - flexShrink: 0, - '&:hover': { - color: 'text.secondary', - bgcolor: 'background.level1', - }, - }} - /> -
{/* All modals (same as original) */}