feat: implement push notification registration and nudge feature; enhance user settings and notification handling

This commit is contained in:
Mo Tarbin
2025-09-18 01:13:37 -04:00
parent b71a533024
commit 707ba55e1d
13 changed files with 672 additions and 320 deletions

View File

@@ -1,8 +1,10 @@
import { App as mobileApp } from '@capacitor/app'
import { Capacitor } from '@capacitor/core'
import { Device } from '@capacitor/device'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
import { PushNotifications } from '@capacitor/push-notifications'
import { PutNotificationTarget } from './utils/Fetcher'
import { RegisterDeviceToken } from './utils/Fetcher'
const localNotificationListenerRegistration = () => {
LocalNotifications.addListener('localNotificationReceived', notification => {
console.log('Notification received', notification)
@@ -18,41 +20,117 @@ const localNotificationListenerRegistration = () => {
}
})
}
const pushNotificationListenerRegistration = () => {
PushNotifications.register()
PushNotifications.addListener('registration', token => {
if (Capacitor.isNativePlatform()) {
const type = Capacitor.getPlatform() === 'android' ? 1 : 2 // 1 for android, 2 for ios
PutNotificationTarget(type, token.value)
.then(response => {
console.log('Notification target updated', response)
})
.catch(error => {
console.error('Error updating notification target', error)
})
// TODO save the token in preferences and only send it if it has changed:
console.log('Push registration success, token: ' + token.value)
const registerTokenIfNeeded = async (token, deviceInfo, deviceId, platform) => {
try {
const stored = await Preferences.get({ key: 'deviceRegistration' })
const lastReg = stored.value ? JSON.parse(stored.value) : null
const current = {
token: token.value,
deviceId: deviceId.identifier,
platform,
appVersion: deviceInfo.appVersion,
registeredAt: Date.now(),
}
})
PushNotifications.addListener('registrationError', error => {
console.error('Error on registration: ' + JSON.stringify(error))
})
PushNotifications.addListener('pushNotificationActionPerformed', fcmEvent => {
if (fcmEvent.actionId === 'tap') {
if (fcmEvent.notification.data.type === 'chore_due') {
window.location.href = `/chores/${fcmEvent.notification.data.choreId}`
} else {
window.location.href = `/chores`
const shouldRegister =
!lastReg ||
lastReg.token !== current.token ||
lastReg.appVersion !== current.appVersion ||
Date.now() - lastReg.registeredAt > 7 * 24 * 60 * 60 * 1000
if (shouldRegister) {
console.log('Registering device token:', {
reason: !lastReg
? 'first_time'
: lastReg.token !== current.token
? 'token_changed'
: lastReg.appVersion !== current.appVersion
? 'app_updated'
: 'periodic_refresh',
})
const result = await RegisterDeviceToken(
token.value,
deviceId.identifier,
platform,
deviceInfo.appVersion,
deviceInfo.model,
)
if (result && !result.error) {
await Preferences.set({
key: 'deviceRegistration',
value: JSON.stringify(current),
})
console.log('Device token registered successfully')
}
} else {
console.log('Device token already registered, skipping')
}
} catch (error) {
console.error(
'Error in token registration check, registering anyway:',
error,
)
await RegisterDeviceToken(
token.value,
deviceId.identifier,
platform,
deviceInfo.appVersion,
deviceInfo.model,
)
}
}
const pushNotificationListenerRegistration = async () => {
// Check and request permissions for Android 13+
if (Capacitor.isNativePlatform()) {
let permStatus = await PushNotifications.checkPermissions()
if (permStatus.receive === 'prompt') {
permStatus = await PushNotifications.requestPermissions()
}
if (permStatus.receive !== 'granted') {
console.warn('Push notification permission not granted')
return
}
}
await PushNotifications.register()
PushNotifications.addListener('registration', async token => {
if (Capacitor.isNativePlatform()) {
try {
const deviceInfo = await Device.getInfo()
const deviceId = await Device.getId()
const platform =
Capacitor.getPlatform() === 'android' ? 'android' : 'ios'
await registerTokenIfNeeded(token, deviceInfo, deviceId, platform)
} catch (error) {
console.error('Error registering device token', error)
}
}
})
PushNotifications.addListener('registrationError', error => {
console.error('Error on registration: ' + JSON.stringify(error))
})
PushNotifications.addListener('pushNotificationReceived', notification => {
console.log('Push notification received: ', notification)
})
PushNotifications.addListener('pushNotificationActionPerformed', fcmEvent => {
if (fcmEvent.actionId === 'tap') {
if (fcmEvent.notification.data.type === 'chore_due') {
if (
fcmEvent.notification.data.type === 'chore_due' ||
fcmEvent.notification.data.type === 'nudge'
) {
window.location.href = `/chores/${fcmEvent.notification.data.choreId}`
} else {
window.location.href = `/chores`
@@ -69,7 +147,6 @@ const registerCapacitorListeners = () => {
return
}
localNotificationListenerRegistration()
pushNotificationListenerRegistration()
mobileApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) {
window.history.back()
@@ -79,4 +156,7 @@ const registerCapacitorListeners = () => {
})
}
export { registerCapacitorListeners }
export {
registerCapacitorListeners,
pushNotificationListenerRegistration as registerPushNotifications,
}

View File

@@ -530,7 +530,7 @@ const UserProfileAvatar = () => {
/>
<SubscriptionModal
isOpen={isSubscriptionModalOpen}
open={isSubscriptionModalOpen}
onClose={() => setIsSubscriptionModalOpen(false)}
/>
</>

View File

@@ -1,6 +1,5 @@
// Animation Components
export { default as AnimatedList } from './AnimatedList'
export { default as LoadingScreen } from './LoadingScreen'
export { default as PageTransition } from './PageTransition'
export { default as SmoothButton } from './SmoothButton'
export { default as SmoothCard } from './SmoothCard'

View File

@@ -1,7 +1,16 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const QueryContext = ({ children }) => {
const queryClient = new QueryClient()
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60000, // 60 seconds
gcTime: 300000, // 5 minutes
refetchOnWindowFocus: false,
retry: 0,
},
},
})
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>

View File

@@ -1,15 +1,12 @@
import { QueryClient } from '@tanstack/react-query'
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import Contexts from './contexts/Contexts.jsx'
import './index.css'
const queryClient = new QueryClient({})
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Contexts queryClient={queryClient}>
<Contexts>
<App />
</Contexts>
</React.StrictMode>,

View File

@@ -172,6 +172,17 @@ const RejectChore = id => {
})
}
const NudgeChore = (id, { message, notifyAllAssignees }) => {
return Fetch(`/chores/${id}/nudge`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
all_assignees: notifyAllAssignees,
message: message || '',
}),
})
}
const UpdateChoreAssignee = (id, assignee) => {
return Fetch(`/chores/${id}/assignee`, {
method: 'PUT',
@@ -657,6 +668,38 @@ const RestoreBackup = (encryptionKey, backupData) => {
})
}
const RegisterDeviceToken = (token, deviceId, platform, appVersion, deviceModel) => {
return Fetch(`/devices/tokens`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
token,
deviceId,
platform,
appVersion,
deviceModel,
}),
})
}
const UnregisterDeviceToken = (deviceId, token) => {
return Fetch(`/devices/tokens`, {
method: 'DELETE',
headers: HEADERS(),
body: JSON.stringify({
deviceId,
token,
}),
})
}
const GetDeviceTokens = (active = true) => {
return Fetch(`/devices/tokens?active=${active}`, {
method: 'GET',
headers: HEADERS(),
})
}
export {
AcceptCircleMemberRequest,
ApproveChore,
@@ -692,6 +735,7 @@ export {
GetChoresHistory,
GetChoresNew,
GetCircleMemberRequests,
GetDeviceTokens,
GetLabels,
GetLongLiveTokens,
GetMFAStatus,
@@ -705,12 +749,14 @@ export {
JoinCircle,
LeaveCircle,
MarkChoreComplete,
NudgeChore,
PauseChore,
PutNotificationTarget,
PutWebhookURL,
RedeemPoints,
RefreshToken,
RegenerateBackupCodes,
RegisterDeviceToken,
RejectChore,
ResetChoreTimer,
ResetPassword,
@@ -721,6 +767,7 @@ export {
SkipChore,
StartChore,
UnArchiveChore,
UnregisterDeviceToken,
UpdateChoreAssignee,
UpdateChoreHistory,
UpdateChorePriority,

View File

@@ -3,6 +3,7 @@ import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
import { Button, Snackbar, Stack, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { registerPushNotifications } from '../../CapacitorListener'
const NotificationAccessSnackbar = () => {
const [open, setOpen] = useState(false)
@@ -56,14 +57,20 @@ const NotificationAccessSnackbar = () => {
<Button
variant='solid'
color='primary'
onClick={() => {
onClick={async () => {
const notificationPreferences = { optOut: false }
LocalNotifications.requestPermissions().then(resp => {
try {
const resp = await LocalNotifications.requestPermissions()
if (resp.display === 'granted') {
notificationPreferences['granted'] = true
// Register for push notifications after local permission is granted
await registerPushNotifications()
}
})
Preferences.set({
} catch (error) {
console.error('Error setting up notifications:', error)
}
await Preferences.set({
key: 'notificationPreferences',
value: JSON.stringify(notificationPreferences),
})

View File

@@ -21,7 +21,6 @@ import {
import moment from 'moment'
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { LoadingScreen } from '../../components/animations'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { ChoreHistoryStatus } from '../../utils/Chores'
import {
@@ -30,8 +29,9 @@ import {
GetChoreHistory,
UpdateChoreHistory,
} from '../../utils/Fetcher'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LoadingComponent from '../components/Loading'
import EditHistoryModal from '../Modals/EditHistoryModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import HistoryCard from './HistoryCard'
const ChoreHistory = () => {
@@ -174,7 +174,7 @@ const ChoreHistory = () => {
}
if (isLoading) {
return <LoadingScreen message='Loading task history...' />
return <LoadingComponent />
}
if (!choreHistory.length) {
return (

View File

@@ -25,7 +25,7 @@ import {
Typography,
} from '@mui/joy'
import moment from 'moment'
import React, { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { TASK_COLOR } from '../../utils/Colors.jsx'
const getCompletedChip = historyEntry => {
@@ -347,45 +347,45 @@ const HistoryCard = ({
onMouseEnter={handleActionAreaMouseEnter}
onMouseLeave={handleActionAreaMouseLeave}
>
{onEditClick && (
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onEditClick(historyEntry)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Edit sx={{ fontSize: 16 }} />
</IconButton>
)}
{onEditClick && (
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onEditClick(historyEntry)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Edit sx={{ fontSize: 16 }} />
</IconButton>
)}
{onDeleteClick && (
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onDeleteClick(historyEntry)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Delete sx={{ fontSize: 16 }} />
</IconButton>
)}
{onDeleteClick && (
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onDeleteClick(historyEntry)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Delete sx={{ fontSize: 16 }} />
</IconButton>
)}
</Box>
)}
@@ -425,219 +425,223 @@ const HistoryCard = ({
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<ListItemContent>
<Grid container spacing={1} alignItems='center'>
{/* First Row/Column: Status and Time Info */}
<Grid xs={12} sm={8}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
{getStatusAvatar()}
<Typography
level='body-sm'
<ListItemContent>
<Grid container spacing={1} alignItems='center'>
{/* First Row/Column: Status and Time Info */}
<Grid xs={12} sm={8}>
<Box
sx={{
color: 'text.secondary',
fontWeight: 'md',
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
{historyEntry.status === 0
? 'In Progress'
: historyEntry.status === 1
? 'Completed'
: historyEntry.status === 2
? 'Skipped'
: historyEntry.status === 3
? 'Pending Approval'
: historyEntry.status === 4
? 'Rejected'
: 'Completed'}
</Typography>
{getStatusAvatar()}
<Chip size='sm' startDecorator={<EventNote />}>
{moment(
historyEntry.performedAt || historyEntry.updatedAt,
).format('MMM DD, h:mm A')}
</Chip>
<Typography
level='body-sm'
sx={{
color: 'text.secondary',
fontWeight: 'md',
}}
>
{historyEntry.status === 0
? 'In Progress'
: historyEntry.status === 1
? 'Completed'
: historyEntry.status === 2
? 'Skipped'
: historyEntry.status === 3
? 'Pending Approval'
: historyEntry.status === 4
? 'Rejected'
: 'Completed'}
</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{getCompletedChip(historyEntry)}
<Chip size='sm' startDecorator={<EventNote />}>
{moment(
historyEntry.performedAt || historyEntry.updatedAt,
).format('MMM DD, h:mm A')}
</Chip>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{getCompletedChip(historyEntry)}
</Box>
</Box>
</Box>
</Grid>
</Grid>
{/* Second Row/Column: Completion Status (right side on desktop) */}
<Grid xs={12} sm={4}>
<Box
sx={{
display: 'flex',
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
alignItems: 'center',
gap: 1,
}}
>
{historyEntry.dueDate && (
<Chip size='sm' startDecorator={<CalendarMonth />}>
{moment(historyEntry.dueDate).format('MMM DD h:mm A')}
</Chip>
)}
</Box>
</Grid>
{/* Third Row: Performer and Assignment Info */}
<Grid xs={12}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
mt: 0.5,
}}
>
<Chip size='sm' variant='outlined' startDecorator={<Person />}>
{performer?.displayName || 'Unknown'}
</Chip>
{historyEntry.completedBy !== historyEntry.assignedTo &&
assignedTo && (
<>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
</Typography>
<Chip
size='sm'
variant='soft'
color='neutral'
startDecorator={<CheckCircle />}
>
{assignedTo.displayName}
</Chip>
</>
{/* Second Row/Column: Completion Status (right side on desktop) */}
<Grid xs={12} sm={4}>
<Box
sx={{
display: 'flex',
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
alignItems: 'center',
gap: 1,
}}
>
{historyEntry.dueDate && (
<Chip size='sm' startDecorator={<CalendarMonth />}>
{moment(historyEntry.dueDate).format('MMM DD h:mm A')}
</Chip>
)}
</Box>
</Grid>
{historyEntry.notes && (
{/* Third Row: Performer and Assignment Info */}
<Grid xs={12}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
mt: 0.5,
}}
>
<Chip
size='sm'
variant='plain'
color='neutral'
startDecorator={<EventNote />}
sx={{ maxWidth: '120px', overflow: 'hidden' }}
variant='outlined'
startDecorator={<Person />}
>
Note
{performer?.displayName || 'Unknown'}
</Chip>
)}
{/* add a duration chip if we have duration */}
{historyEntry?.duration > 0 && (
<Chip
size='sm'
variant='soft'
color='primary'
startDecorator={<AccessTime />}
>
{formatTime(historyEntry.duration)}
</Chip>
)}
{historyEntry?.points > 0 && (
<Chip
size='sm'
variant='solid'
color='success'
startDecorator={<Toll />}
>
{historyEntry.points} pt
{historyEntry.points > 1 ? 's' : ''}
</Chip>
)}
</Box>
{historyEntry.completedBy !== historyEntry.assignedTo &&
assignedTo && (
<>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
</Typography>
<Chip
size='sm'
variant='soft'
color='neutral'
startDecorator={<CheckCircle />}
>
{assignedTo.displayName}
</Chip>
</>
)}
{historyEntry.notes && (
<Chip
size='sm'
variant='plain'
color='neutral'
startDecorator={<EventNote />}
sx={{ maxWidth: '120px', overflow: 'hidden' }}
>
Note
</Chip>
)}
{/* add a duration chip if we have duration */}
{historyEntry?.duration > 0 && (
<Chip
size='sm'
variant='soft'
color='primary'
startDecorator={<AccessTime />}
>
{formatTime(historyEntry.duration)}
</Chip>
)}
{historyEntry?.points > 0 && (
<Chip
size='sm'
variant='solid'
color='success'
startDecorator={<Toll />}
>
{historyEntry.points} pt
{historyEntry.points > 1 ? 's' : ''}
</Chip>
)}
</Box>
</Grid>
</Grid>
</Grid>
</ListItemContent>
</ListItemContent>
{/* Right drag area - only triggers reveal on hover */}
{(onEditClick || onDeleteClick) && (
<Box
sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '20px',
cursor: 'grab',
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: isSwipeRevealed ? 0 : 0.3, // Hide when action area is revealed
transition: 'opacity 0.2s ease',
pointerEvents: isSwipeRevealed ? 'none' : 'auto', // Disable pointer events when revealed
'&:hover': {
opacity: isSwipeRevealed ? 0 : 0.7,
},
'&:active': {
cursor: 'grabbing',
},
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Drag indicator dots */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.25,
}}
>
{[...Array(3)].map((_, i) => (
{/* Right drag area - only triggers reveal on hover */}
{(onEditClick || onDeleteClick) && (
<Box
sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '20px',
cursor: 'grab',
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: isSwipeRevealed ? 0 : 0.3, // Hide when action area is revealed
transition: 'opacity 0.2s ease',
pointerEvents: isSwipeRevealed ? 'none' : 'auto', // Disable pointer events when revealed
'&:hover': {
opacity: isSwipeRevealed ? 0 : 0.7,
},
'&:active': {
cursor: 'grabbing',
},
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Drag indicator dots */}
<Box
key={i}
sx={{
width: 3,
height: 3,
borderRadius: '50%',
backgroundColor: 'text.tertiary',
display: 'flex',
flexDirection: 'column',
gap: 0.25,
}}
/>
))}
</Box>
</Box>
)}
</ListItem>
>
{[...Array(3)].map((_, i) => (
<Box
key={i}
sx={{
width: 3,
height: 3,
borderRadius: '50%',
backgroundColor: 'text.tertiary',
}}
/>
))}
</Box>
</Box>
)}
</ListItem>
{/* Compact Divider with Time Difference */}
{index < allHistory.length - 1 && allHistory[index + 1].performedAt && (
<ListDivider
component='li'
sx={{
my: 0.5,
}}
>
<Typography
level='body-xs'
{/* Compact Divider with Time Difference */}
{index < allHistory.length - 1 && allHistory[index + 1].performedAt && (
<ListDivider
component='li'
sx={{
color: 'text.tertiary',
backgroundColor: 'background.surface',
px: 1,
fontSize: '0.75rem',
my: 0.5,
}}
>
{formatTimeDifference(
historyEntry.performedAt || historyEntry.updatedAt,
allHistory[index + 1].performedAt,
)}{' '}
before
</Typography>
</ListDivider>
)}
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
backgroundColor: 'background.surface',
px: 1,
fontSize: '0.75rem',
}}
>
{formatTimeDifference(
historyEntry.performedAt || historyEntry.updatedAt,
allHistory[index + 1].performedAt,
)}{' '}
before
</Typography>
</ListDivider>
)}
</Box>
</>
)

View File

@@ -0,0 +1,158 @@
import {
Box,
Button,
FormControl,
FormLabel,
Switch,
Textarea,
Typography,
} from '@mui/joy'
import { useCallback, useEffect, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function NudgeModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [message, setMessage] = useState('')
const [notifyAllAssignees, setNotifyAllAssignees] = useState(false)
const handleAction = useCallback(
isConfirmed => {
if (isConfirmed) {
config.onConfirm({ choreId: config.choreId, message, notifyAllAssignees })
} else {
config.onClose()
}
},
[config, message, notifyAllAssignees],
)
// Reset form when modal opens
useEffect(() => {
if (config?.isOpen) {
setMessage('')
setNotifyAllAssignees(false)
}
}, [config?.isOpen])
// Keyboard shortcuts for nudge modal
useEffect(() => {
const handleKeyDown = event => {
if (!config?.isOpen) return
// Show keyboard shortcuts when Ctrl/Cmd is pressed
if (event.ctrlKey || event.metaKey) {
setShowKeyboardShortcuts(true)
}
// Ctrl/Cmd + Y for confirm
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
event.preventDefault()
handleAction(true)
return
}
// Ctrl/Cmd + X for cancel
if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
event.preventDefault()
handleAction(false)
return
}
// Escape key for cancel
if (event.key === 'Escape') {
event.preventDefault()
handleAction(false)
return
}
}
const handleKeyUp = event => {
if (!event.ctrlKey && !event.metaKey) {
setShowKeyboardShortcuts(false)
}
}
if (config?.isOpen) {
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
}
}, [config?.isOpen, handleAction])
return (
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
size='md'
unmountDelay={250}
>
<Typography level='h4' mb={2}>
Send Nudge
</Typography>
<Typography level='body-md' mb={2}>
Send a gentle reminder to the assignee about this task. You can
customize the message and choose who gets notified.
</Typography>
<FormControl mb={2}>
<FormLabel>Custom Message (optional)</FormLabel>
<Textarea
placeholder='Add a personal message with your nudge...'
value={message}
onChange={e => setMessage(e.target.value)}
minRows={3}
maxRows={5}
/>
</FormControl>
<FormControl orientation='horizontal' sx={{ mb: 3 }}>
<Box sx={{ flex: 1 }}>
<FormLabel>Notify All Assignees</FormLabel>
<Typography level='body-sm' color='text.secondary'>
If enabled, all members who can see this task will be notified.
Otherwise, only the assigned person will receive the nudge.
</Typography>
</Box>
<Switch
checked={notifyAllAssignees}
onChange={e => setNotifyAllAssignees(e.target.checked)}
/>
</FormControl>
<Box display={'flex'} justifyContent={'space-around'} gap={1}>
<Button
size='lg'
onClick={() => handleAction(true)}
fullWidth
color='primary'
endDecorator={
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
}
>
Send Nudge
</Button>
<Button
size='lg'
onClick={() => handleAction(false)}
variant='outlined'
fullWidth
endDecorator={
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
}
>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}
export default NudgeModal

View File

@@ -12,7 +12,7 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
<List sx={{ mb: 2 }}>
{performers.map(user => (
<ListItem
key={user.id}
key={user.userId}
sx={{
cursor: 'pointer',
'&:hover': {

View File

@@ -17,6 +17,8 @@ import {
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { PushNotifications } from '@capacitor/push-notifications'
import { registerPushNotifications } from '../../CapacitorListener'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import {
@@ -236,55 +238,62 @@ const NotificationSetting = () => {
))}
</Card>
)}
{/* <FormControl
orientation="horizontal"
sx={{ width: 400, justifyContent: 'space-between' }}
>
<div>
<FormLabel>Push Notifications</FormLabel>
<FormHelperText sx={{ mt: 0 }}>{Capacitor.isNativePlatform()? 'Receive push notification when someone complete task' : 'This feature is only available on mobile devices'} </FormHelperText>
</div>
<Switch
disabled={!Capacitor.isNativePlatform()}
checked={pushNotification}
onClick={(event) =>{
event.preventDefault()
if (pushNotification === false){
PushNotifications.requestPermissions().then((resp) => {
console.log("user PushNotifications permission",resp);
if (resp.receive === 'granted') {
setPushNotification(true)
setPushNotificationPreferences({granted: true})
<FormControl
orientation='horizontal'
sx={{ width: 400, justifyContent: 'space-between' }}
>
<div>
<FormLabel>Push Notifications</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
{Capacitor.isNativePlatform()
? 'Receive Nudges, Announcements, and Chore Assignments via Push Notifications'
: 'This feature is only available on mobile devices'}{' '}
</FormHelperText>
</div>
<Switch
disabled={!Capacitor.isNativePlatform()}
checked={pushNotification}
onClick={async event => {
event.preventDefault()
if (pushNotification === false) {
try {
const resp = await PushNotifications.requestPermissions()
console.log('user PushNotifications permission', resp)
if (resp.receive === 'granted') {
setPushNotification(true)
setPushNotificationPreferences({ granted: true })
// Register push notifications after permission is granted
await registerPushNotifications()
}
if (resp.receive !== 'granted') {
showWarning({
title: 'Push Notification Permission Denied',
message:
'Push notifications have been disabled. You can enable them in your device settings if needed.',
})
setPushNotification(false)
setPushNotificationPreferences({ granted: false })
console.log('User denied permission', resp)
}
} catch (error) {
console.error('Error setting up push notifications:', error)
}
if (resp.receive !== 'granted') {
showWarning({
title: 'Push Notification Permission Denied',
message: 'Push notifications have been disabled. You can enable them in your device settings if needed.',
})
setPushNotification(false)
setPushNotificationPreferences({granted: false})
console.log("User denied permission", resp)
}
})
}
else{
setPushNotification(false)
}
}
}
color={pushNotification ? 'success' : 'neutral'}
variant={pushNotification ? 'solid' : 'outlined'}
endDecorator={pushNotification ? 'On' : 'Off'}
slotProps={{
endDecorator: {
sx: {
minWidth: 24,
} else {
setPushNotification(false)
}
}}
color={pushNotification ? 'success' : 'neutral'}
variant={pushNotification ? 'solid' : 'outlined'}
endDecorator={pushNotification ? 'On' : 'Off'}
slotProps={{
endDecorator: {
sx: {
minWidth: 24,
},
},
},
}}
/>
</FormControl> */}
}}
/>
</FormControl>
<Button
variant='soft'

View File

@@ -158,6 +158,48 @@ const StorageSettings = () => {
</Button>
</Card>
{Capacitor.isNativePlatform() && (
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
App Preferences
<Chip variant='soft' color='info' sx={{ ml: 1 }}>
Device Only
</Chip>
</Typography>
<Typography level='body-sm' sx={{ mb: 1 }}>
These are preferences and settings stored locally on your device
by the app. Clearing them will reset app-specific settings and may
log you out, but will not affect your server data.
</Typography>
<Button
variant='soft'
color='danger'
onClick={() => {
showConfirmation(
'Are you sure you want to clear all app preferences? This will reset your app settings and may require you to log in again.',
'Clear App Preferences',
async () => {
try {
const { Preferences } = await import(
'@capacitor/preferences'
)
await Preferences.clear()
Navigate('/login')
} catch (e) {
// Optionally show error feedback
}
},
'Clear Preferences',
'Cancel',
'danger',
)
}}
>
Clear App Preferences
</Button>
</Card>
)}
{/* Modals */}
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />