feat: implement push notification registration and nudge feature; enhance user settings and notification handling
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -530,7 +530,7 @@ const UserProfileAvatar = () => {
|
||||
/>
|
||||
|
||||
<SubscriptionModal
|
||||
isOpen={isSubscriptionModalOpen}
|
||||
open={isSubscriptionModalOpen}
|
||||
onClose={() => setIsSubscriptionModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
158
src/views/Modals/Inputs/NudgeModal.jsx
Normal file
158
src/views/Modals/Inputs/NudgeModal.jsx
Normal 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
|
||||
@@ -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': {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user