Add BottomSheetModal component and implement calendar view in MyChores
This commit is contained in:
224
src/components/common/BottomSheetModal.jsx
Normal file
224
src/components/common/BottomSheetModal.jsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||
import { forwardRef, useEffect, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const BottomSheetModal = forwardRef(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
title,
|
||||
height = 'auto',
|
||||
maxHeight = '90vh',
|
||||
expandedHeight = '95vh',
|
||||
backdropBlur = true,
|
||||
showHandle = true,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
const [internalOpen, setInternalOpen] = useState(open)
|
||||
|
||||
// Handle opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInternalOpen(true)
|
||||
setIsClosing(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Handle closing with animation
|
||||
useEffect(() => {
|
||||
if (!open && internalOpen) {
|
||||
setIsClosing(true)
|
||||
// Wait for animation to complete before hiding modal
|
||||
const timer = setTimeout(() => {
|
||||
setInternalOpen(false)
|
||||
setIsClosing(false)
|
||||
setIsExpanded(false)
|
||||
}, 250) // Match transition duration
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, internalOpen])
|
||||
|
||||
// Handle toggle expansion
|
||||
const handleToggleExpansion = () => {
|
||||
setIsExpanded(prev => !prev)
|
||||
}
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = event => {
|
||||
if (event.key === 'Escape' && internalOpen) {
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
if (internalOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
// Prevent body scroll when modal is open
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [internalOpen, onClose])
|
||||
|
||||
// Calculate current height
|
||||
const currentHeight = isExpanded ? expandedHeight : height
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={internalOpen}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'& .MuiModal-backdrop': {
|
||||
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
keepMounted
|
||||
{...props}
|
||||
>
|
||||
<Sheet
|
||||
ref={ref}
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
width: '100%',
|
||||
height: currentHeight,
|
||||
maxHeight: isExpanded ? expandedHeight : maxHeight,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
p: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition:
|
||||
'height 0.3s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
transform:
|
||||
open && !isClosing ? 'translateY(0)' : 'translateY(100%)',
|
||||
// Handle safe area on mobile devices
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
{/* Header Section with drag handle, title, and close button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'inherit',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Close button positioned absolutely in top-right */}
|
||||
{showCloseButton && (
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 16,
|
||||
zIndex: 1,
|
||||
borderRadius: '50%',
|
||||
width: 32,
|
||||
height: 32,
|
||||
backgroundColor: 'neutral.softBg',
|
||||
color: 'neutral.softColor',
|
||||
'&:hover': {
|
||||
backgroundColor: 'neutral.softHoverBg',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Close fontSize='small' />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* Drag Handle */}
|
||||
{showHandle && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '12px 0 8px 0',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={handleToggleExpansion}
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'var(--joy-palette-neutral-300)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title Row */}
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: showHandle ? '0 20px 16px 20px' : '16px 20px 16px 20px',
|
||||
paddingRight: showCloseButton ? '60px' : '20px', // Add space for close button
|
||||
minHeight: 24,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='title-lg'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Content area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '0 20px 20px 20px',
|
||||
minHeight: 0, // Important for flex child with overflow
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Sheet>
|
||||
</Modal>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
BottomSheetModal.displayName = 'BottomSheetModal'
|
||||
|
||||
export default BottomSheetModal
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Add,
|
||||
Archive,
|
||||
Bolt,
|
||||
CalendarMonth,
|
||||
CancelRounded,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
@@ -51,12 +52,15 @@ import CompactChoreCard from './CompactChoreCard'
|
||||
import IconButtonWithMenu from './IconButtonWithMenu'
|
||||
import MultiSelectHelp from './MultiSelectHelp'
|
||||
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
|
||||
import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher'
|
||||
import TaskInput from '../components/AddTaskModal'
|
||||
import CalendarDual from '../components/CalendarDual'
|
||||
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
||||
import {
|
||||
canScheduleNotification,
|
||||
scheduleChoreNotification,
|
||||
@@ -68,6 +72,7 @@ import SortAndGrouping from './SortAndGrouping'
|
||||
const MyChores = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
|
||||
const { showSuccess, showError, showWarning } = useNotification()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const [chores, setChores] = useState([])
|
||||
@@ -93,9 +98,10 @@ const MyChores = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [performers, setPerformers] = useState([])
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const [isCompactView, setIsCompactView] = useState(
|
||||
localStorage.getItem('choreCardViewMode') === 'compact',
|
||||
const [viewMode, setViewMode] = useState(
|
||||
localStorage.getItem('choreCardViewMode') || 'default',
|
||||
)
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState(new Date())
|
||||
const menuRef = useRef(null)
|
||||
const Navigate = useNavigate()
|
||||
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||
@@ -385,17 +391,27 @@ const MyChores = () => {
|
||||
const setSelectedChoreFilterWithCache = value => {
|
||||
setSelectedChoreFilter(value)
|
||||
localStorage.setItem('selectedChoreFilter', value)
|
||||
// Clear selected calendar date when filters change
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
|
||||
const toggleViewMode = () => {
|
||||
const newMode = !isCompactView
|
||||
setIsCompactView(newMode)
|
||||
localStorage.setItem('choreCardViewMode', newMode ? 'compact' : 'default')
|
||||
const modes = ['default', 'compact', 'calendar']
|
||||
const currentIndex = modes.indexOf(viewMode)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
const newMode = modes[nextIndex]
|
||||
setViewMode(newMode)
|
||||
localStorage.setItem('choreCardViewMode', newMode)
|
||||
|
||||
// Clear selected calendar date when switching away from calendar view
|
||||
if (newMode !== 'calendar') {
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to render the appropriate card component
|
||||
const renderChoreCard = (chore, key) => {
|
||||
const CardComponent = isCompactView ? CompactChoreCard : ChoreCard
|
||||
const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
|
||||
return (
|
||||
<CardComponent
|
||||
key={key || chore.id}
|
||||
@@ -413,6 +429,25 @@ const MyChores = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to get filtered chores for display
|
||||
const getFilteredChores = () => {
|
||||
if (searchTerm?.length > 0 || searchFilter !== 'All') {
|
||||
return filteredChores
|
||||
}
|
||||
return chores.filter(ChoreFilters(userProfile)[selectedChoreFilter])
|
||||
}
|
||||
|
||||
// Helper function to get chores for a specific date
|
||||
const getChoresForDate = date => {
|
||||
const filteredChores = getFilteredChores()
|
||||
return filteredChores.filter(chore => {
|
||||
if (!chore.nextDueDate) return false
|
||||
const choreDate = new Date(chore.nextDueDate).toLocaleDateString()
|
||||
const selectedDate = date.toLocaleDateString()
|
||||
return choreDate === selectedDate
|
||||
})
|
||||
}
|
||||
|
||||
const updateChores = newChore => {
|
||||
const newChores = chores
|
||||
newChores.push(newChore)
|
||||
@@ -463,6 +498,8 @@ const MyChores = () => {
|
||||
setFilteredChores(priorityFiltered)
|
||||
setSearchFilter('Priority: ' + priority)
|
||||
}
|
||||
// Clear selected calendar date when filters change
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
|
||||
const handleChoreUpdated = (updatedChore, event) => {
|
||||
@@ -607,18 +644,24 @@ const MyChores = () => {
|
||||
if (search === '') {
|
||||
setFilteredChores(chores)
|
||||
setSearchTerm('')
|
||||
// Clear selected calendar date when search changes
|
||||
setSelectedCalendarDate(null)
|
||||
return
|
||||
}
|
||||
|
||||
const term = search.toLowerCase()
|
||||
setSearchTerm(term)
|
||||
setFilteredChores(fuse.search(term).map(result => result.item))
|
||||
// Clear selected calendar date when search changes
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
const handleSearchClose = () => {
|
||||
setSearchTerm('')
|
||||
setFilteredChores(chores)
|
||||
// remove the focus from the search input:
|
||||
setSearchInputFocus(0)
|
||||
// Clear selected calendar date when search closes
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
|
||||
// Multi-select helper functions
|
||||
@@ -1065,10 +1108,20 @@ const MyChores = () => {
|
||||
}}
|
||||
onClick={toggleViewMode}
|
||||
title={
|
||||
isCompactView ? 'Switch to Card View' : 'Switch to Compact View'
|
||||
viewMode === 'default'
|
||||
? 'Switch to Compact View'
|
||||
: viewMode === 'compact'
|
||||
? 'Switch to Calendar View'
|
||||
: 'Switch to Card View'
|
||||
}
|
||||
>
|
||||
{isCompactView ? <ViewModule /> : <ViewAgenda />}
|
||||
{viewMode === 'default' ? (
|
||||
<ViewAgenda />
|
||||
) : viewMode === 'compact' ? (
|
||||
<CalendarMonth />
|
||||
) : (
|
||||
<ViewModule />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{/* Multi-select Toggle Button */}
|
||||
@@ -1579,79 +1632,153 @@ const MyChores = () => {
|
||||
</Box>
|
||||
)}
|
||||
{(searchTerm?.length > 0 || searchFilter !== 'All') &&
|
||||
viewMode !== 'calendar' &&
|
||||
filteredChores.map(chore =>
|
||||
renderChoreCard(chore, `filtered-${chore.id}`),
|
||||
)}
|
||||
{searchTerm.length === 0 && searchFilter === 'All' && (
|
||||
<AccordionGroup transition='0.2s ease' disableDivider>
|
||||
{choreSections.map((section, index) => {
|
||||
if (section.content.length === 0) return null
|
||||
return (
|
||||
<Accordion
|
||||
key={section.name + index}
|
||||
sx={{
|
||||
my: 0,
|
||||
px: 0,
|
||||
{viewMode === 'calendar' && (
|
||||
<>
|
||||
{/* Calendar Monthly View */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
{isLargeScreen ? (
|
||||
<CalendarDual
|
||||
chores={getFilteredChores()}
|
||||
onDateChange={date => {
|
||||
setSelectedCalendarDate(date)
|
||||
}}
|
||||
expanded={Boolean(openChoreSections[index])}
|
||||
>
|
||||
<Divider orientation='horizontal'>
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
if (openChoreSections[index]) {
|
||||
const newOpenChoreSections = {
|
||||
...openChoreSections,
|
||||
}
|
||||
delete newOpenChoreSections[index]
|
||||
setOpenChoreSectionsWithCache(newOpenChoreSections)
|
||||
} else {
|
||||
setOpenChoreSectionsWithCache({
|
||||
...openChoreSections,
|
||||
[index]: true,
|
||||
})
|
||||
}
|
||||
}}
|
||||
endDecorator={
|
||||
openChoreSections[index] ? (
|
||||
<ExpandCircleDown
|
||||
color='primary'
|
||||
sx={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
) : (
|
||||
<ExpandCircleDown color='primary' />
|
||||
)
|
||||
}
|
||||
startDecorator={
|
||||
<>
|
||||
<Chip color='primary' size='sm' variant='soft'>
|
||||
{section?.content?.length}
|
||||
</Chip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{section.name}
|
||||
</Chip>
|
||||
</Divider>
|
||||
<AccordionDetails
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
['& > *']: {
|
||||
// px: 0.5,
|
||||
px: 0.5,
|
||||
// pr: 0,
|
||||
},
|
||||
/>
|
||||
) : (
|
||||
<div className='calendar-dual'>
|
||||
<CalendarMonthly
|
||||
chores={getFilteredChores()}
|
||||
onDateChange={date => {
|
||||
setSelectedCalendarDate(date)
|
||||
}}
|
||||
>
|
||||
{section.content?.map(chore => renderChoreCard(chore))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
})}
|
||||
</AccordionGroup>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Selected Date Tasks */}
|
||||
{selectedCalendarDate && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography level='title-md' gutterBottom>
|
||||
Tasks for {selectedCalendarDate.toLocaleDateString()}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{getChoresForDate(selectedCalendarDate).length === 0 ? (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 2,
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
>
|
||||
No tasks scheduled for this date
|
||||
</Typography>
|
||||
) : (
|
||||
getChoresForDate(selectedCalendarDate).map(chore => (
|
||||
<CompactChoreCard
|
||||
key={`calendar-${chore.id}`}
|
||||
chore={chore}
|
||||
onChoreUpdate={handleChoreUpdated}
|
||||
onChoreRemove={handleChoreDeleted}
|
||||
performers={performers}
|
||||
userLabels={userLabels}
|
||||
onChipClick={handleLabelFiltering}
|
||||
// Multi-select props
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
isSelected={selectedChores.has(chore.id)}
|
||||
onSelectionToggle={() => toggleChoreSelection(chore.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{searchTerm.length === 0 &&
|
||||
searchFilter === 'All' &&
|
||||
viewMode !== 'calendar' && (
|
||||
<AccordionGroup transition='0.2s ease' disableDivider>
|
||||
{choreSections.map((section, index) => {
|
||||
if (section.content.length === 0) return null
|
||||
return (
|
||||
<Accordion
|
||||
key={section.name + index}
|
||||
sx={{
|
||||
my: 0,
|
||||
px: 0,
|
||||
}}
|
||||
expanded={Boolean(openChoreSections[index])}
|
||||
>
|
||||
<Divider orientation='horizontal'>
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
if (openChoreSections[index]) {
|
||||
const newOpenChoreSections = {
|
||||
...openChoreSections,
|
||||
}
|
||||
delete newOpenChoreSections[index]
|
||||
setOpenChoreSectionsWithCache(newOpenChoreSections)
|
||||
} else {
|
||||
setOpenChoreSectionsWithCache({
|
||||
...openChoreSections,
|
||||
[index]: true,
|
||||
})
|
||||
}
|
||||
}}
|
||||
endDecorator={
|
||||
openChoreSections[index] ? (
|
||||
<ExpandCircleDown
|
||||
color='primary'
|
||||
sx={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
) : (
|
||||
<ExpandCircleDown color='primary' />
|
||||
)
|
||||
}
|
||||
startDecorator={
|
||||
<>
|
||||
<Chip color='primary' size='sm' variant='soft'>
|
||||
{section?.content?.length}
|
||||
</Chip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{section.name}
|
||||
</Chip>
|
||||
</Divider>
|
||||
<AccordionDetails
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
['& > *']: {
|
||||
// px: 0.5,
|
||||
px: 0.5,
|
||||
// pr: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{section.content?.map(chore => renderChoreCard(chore))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
})}
|
||||
</AccordionGroup>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
// center the button
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Box, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Calendar from 'react-calendar'
|
||||
// import 'react-calendar/dist/Calendar.css'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { getPriorityColor, TASK_COLOR } from '../../utils/Colors'
|
||||
|
||||
@@ -2,7 +2,6 @@ import { CalendarMonth } from '@mui/icons-material'
|
||||
import { Avatar, Box, Chip, Grid, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useState } from 'react'
|
||||
// import 'react-calendar/dist/Calendar.css'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { getPriorityColor, TASK_COLOR } from '../../utils/Colors'
|
||||
|
||||
Reference in New Issue
Block a user