Mobile app (#3)

* Initial Capacitor Config and plugins

* Add Android project files and resources

* Add local notification scheduling for chores

* Add NotificationAccessSnackbar component for handling notification preferences

* Add capacitor-preferences to Android project

* Update notification Snackbar

* Add local notification scheduling for chores

* Add ionic.config.json file for custom project configuration

* chore: Add environment variables for production deployment

* Add Support for IOS, pass notificaiton token(push notifications)

* Add Capacitor Device support and refactor notification handling

* Refactor GoogleAuth client IDs to use environment variables

* Remove google-services.json to enhance security by eliminating sensitive data from the repository

* Remove environment files to enhance security by eliminating sensitive data from the repository

* Rename project from fe-template to Donetick in ionic.config.json

* Remove GoogleService-Info.plist and Info.plist to enhance security by eliminating sensitive data from the repository

---------

Co-authored-by: Mo Tarbin <mohamad@Mos-MacBook-Pro.local>
This commit is contained in:
Mohamad Tarbin 2024-12-26 02:13:47 -05:00 committed by GitHub
parent 1e7b47e783
commit bcd32a8616
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
130 changed files with 6699 additions and 880 deletions

View file

@ -14,6 +14,7 @@ import {
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { API_URL } from './../../Config'
import { ResetPassword } from '../../utils/Fetcher'
const ForgotPasswordView = () => {
const navigate = useNavigate()
@ -43,13 +44,7 @@ const ForgotPasswordView = () => {
}
try {
const response = await fetch(`${API_URL}/auth/reset`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: email }),
})
const response = await ResetPassword(email)
if (response.ok) {
setResetStatusOk(true)

View file

@ -0,0 +1,181 @@
import GoogleIcon from '@mui/icons-material/Google'
import {
Avatar,
Box,
Button,
Container,
Divider,
IconButton,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import React from 'react'
import { LoginSocialGoogle } from 'reactjs-social-login'
import { API_URL, GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import Logo from '../../Logo'
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
import { Capacitor } from '@capacitor/core'
import { Settings } from '@mui/icons-material'
import { useNavigate } from 'react-router-dom'
import { Preferences } from '@capacitor/preferences'
const LoginSettings = () => {
const [error, setError] = React.useState(null)
const Navigate = useNavigate()
const [serverURL, setServerURL] = React.useState('')
React.useEffect(() => {
Preferences.get({ key: 'customServerUrl' }).then((result) => {
setServerURL(result.value || API_URL)
})
}, [])
const isValidServerURL = () => {
return serverURL.match(/^(http|https):\/\/[^ "]+$/)
}
return (
<Container
component='main'
maxWidth='xs'
>
<Box
sx={{
marginTop: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Sheet
component='form'
sx={{
mt: 1,
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: 2,
borderRadius: '8px',
boxShadow: 'md',
}}
>
<Logo />
<Typography level='h2'>
Done
<span
style={{
color: '#06b6d4',
}}
>
tick
</span>
</Typography>
<Typography level='body2' alignSelf={'start'} mt={4} >
Server URL
</Typography>
<Input
margin='normal'
required
fullWidth
id='serverURL'
name='serverURL'
autoFocus
value={serverURL}
onChange={e => {
setServerURL(e.target.value)
}}
/>
<Typography mt={1} level='body-xs'>
Change the server URL to connect to a different server, such as your own self-hosted Donetick server.
</Typography>
<Typography mt={1} level='body-xs'>
Please ensure to include the protocol (http:// or https://) and the port number if necessary (default Donetick port is 2021).
</Typography>
<Button
fullWidth
size='lg'
variant='solid'
sx={{
width: '100%',
mt: 3,
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
if (serverURL === '') {
Preferences.set({ key: 'customServerUrl', value: API_URL }).then(() => {
Navigate('/login')
})
return
}
if (!isValidServerURL()){
setError('Invalid server URL')
return
}
Preferences.set({ key: 'customServerUrl', value: serverURL }).then(() => {
Navigate('/login')
}
)
}
}
>
Save
</Button>
<Button
fullWidth
size='lg'
variant='soft'
color='danger'
sx={{
width: '100%',
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
Preferences.set({ key: 'customServerUrl', value: API_URL }).then(() => {
Navigate('/login')
})
}
}
>
Cancel and Reset
</Button>
</Sheet>
</Box>
<Snackbar
open={error !== null}
onClose={() => setError(null)}
autoHideDuration={3000}
message={error}
>
{error}
</Snackbar>
</Container>
)
}
export default LoginSettings

View file

@ -5,6 +5,7 @@ import {
Button,
Container,
Divider,
IconButton,
Input,
Sheet,
Snackbar,
@ -17,7 +18,13 @@ import { LoginSocialGoogle } from 'reactjs-social-login'
import { API_URL, GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import { UserContext } from '../../contexts/UserContext'
import Logo from '../../Logo'
import { GetUserProfile } from '../../utils/Fetcher'
import { GetUserProfile, login } from '../../utils/Fetcher'
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
import { Capacitor } from '@capacitor/core'
import { Settings } from '@mui/icons-material'
const LoginView = () => {
const { userProfile, setUserProfile } = React.useContext(UserContext)
const [username, setUsername] = React.useState('')
@ -26,14 +33,8 @@ const LoginView = () => {
const Navigate = useNavigate()
const handleSubmit = async e => {
e.preventDefault()
fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
login(username, password)
.then(response => {
if (response.status === 200) {
return response.json().then(data => {
@ -71,7 +72,7 @@ const LoginView = () => {
provider: provider,
token:
data['access_token'] || // data["access_token"] is for Google
data['accessToken'], // data["accessToken"] is for Facebook
data['accessToken'], // data["accessToken"] is for Google Capacitor
data: data,
}),
}).then(response => {
@ -140,6 +141,20 @@ const LoginView = () => {
boxShadow: 'md',
}}
>
<IconButton
// on top right of the screen:
sx={{
position: 'absolute',
top: 2,
right: 2,
color: 'black',
}}
onClick={() => {
Navigate('/login/settings')
}
}
> <Settings /></IconButton>
{/* <img
src='/src/assets/logo.svg'
alt='logo'
@ -285,7 +300,7 @@ const LoginView = () => {
</>
)}
<Divider> or </Divider>
{!Capacitor.isNativePlatform() && (
<Box sx={{ width: '100%' }}>
<LoginSocialGoogle
client_id={GOOGLE_CLIENT_ID}
@ -320,7 +335,32 @@ const LoginView = () => {
</div>
</Button>
</LoginSocialGoogle>
</Box> )}
{Capacitor.isNativePlatform() && (
<Box sx={{ width: '100%' }}>
<Button fullWidth variant='soft' size='lg' sx={{ mt: 3, mb: 2 }}
onClick={()=>{
GoogleAuth.initialize({
clientId: import.meta.env.VITE_APP_GOOGLE_CLIENT_ID,
scopes: ['profile', 'email','openid'],
grantOfflineAccess: true,
});
GoogleAuth.signIn().then((user) => {
console.log("Google user", user);
loggedWithProvider("google", user.authentication)
});
}}>
<div className='flex gap-2'>
<GoogleIcon />
Continue with Google
</div>
</Button>
</Box>
)}
<Button
onClick={() => {

View file

@ -15,6 +15,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'
import { API_URL } from '../../Config'
import Logo from '../../Logo'
import { ChangePassword } from '../../utils/Fetcher'
const UpdatePasswordView = () => {
const navigate = useNavigate()
@ -52,17 +53,8 @@ const UpdatePasswordView = () => {
return
}
try {
const response = await fetch(
`${API_URL}/auth/password?c=${verifiticationCode}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password: password }),
},
)
const response = await ChangePassword(verifiticationCode, password)
if (response.ok) {
setUpdateStatusOk(true)
// wait 3 seconds and then redirect to login:

View file

@ -190,7 +190,7 @@ const ChoreView = () => {
}, 1000)
const id = setTimeout(() => {
MarkChoreComplete(choreId, note, completedDate)
MarkChoreComplete(choreId, note, completedDate, null)
.then(resp => {
if (resp.ok) {
return resp.json().then(data => {

View file

@ -46,10 +46,12 @@ import { API_URL } from '../../Config'
import { UserContext } from '../../contexts/UserContext'
import {
ArchiveChore,
DeleteChore,
MarkChoreComplete,
SkipChore,
UnArchiveChore,
UpdateChoreAssignee,
UpdateDueDate,
} from '../../utils/Fetcher'
import { getTextColorFromBackgroundColor } from '../../utils/LabelColors'
import Priorities from '../../utils/Priorities'
@ -127,11 +129,9 @@ const ChoreCard = ({
cancelText: 'Cancel',
message: 'Are you sure you want to delete this chore?',
onClose: isConfirmed => {
console.log('isConfirmed', isConfirmed)
if (isConfirmed === true) {
Fetch(`${API_URL}/chores/${chore.id}`, {
method: 'DELETE',
}).then(response => {
DeleteChore(chore.id)
.then(response => {
if (response.ok) {
onChoreRemove(chore)
}
@ -181,7 +181,7 @@ const ChoreCard = ({
}, 1000)
const id = setTimeout(() => {
MarkChoreComplete(chore.id)
MarkChoreComplete(chore.id, null, null,null)
.then(resp => {
if (resp.ok) {
return resp.json().then(data => {
@ -206,16 +206,7 @@ const ChoreCard = ({
alert('Please select a performer')
return
}
Fetch(`${API_URL}/chores/${chore.id}/dueDate`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
dueDate: newDate ? new Date(newDate).toISOString() : null,
UpdatedBy: activeUserId,
}),
}).then(response => {
UpdateDueDate.then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
@ -230,18 +221,9 @@ const ChoreCard = ({
alert('Please select a performer')
return
}
Fetch(
`${API_URL}/chores/${chore.id}/do?completedDate=${new Date(
newDate,
).toISOString()}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
},
).then(response => {
MarkChoreComplete(chore.id, null, new Date(newDate).toISOString(), null)
.then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
@ -261,15 +243,9 @@ const ChoreCard = ({
})
}
const handleCompleteWithNote = note => {
Fetch(`${API_URL}/chores/${chore.id}/do`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
note: note,
}),
}).then(response => {
MarkChoreComplete(chore.id, note, null, null)
.then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res

View file

@ -0,0 +1,128 @@
import { Capacitor } from '@capacitor/core';
import { LocalNotifications } from '@capacitor/local-notifications';
import { Preferences } from '@capacitor/preferences';
const getNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'notificationPreferences' });
return JSON.parse(ret.value);
};
const canScheduleNotification = () => {
if (Capacitor.isNativePlatform() === false) {
return false;
}
const notificationPreferences = getNotificationPreferences();
if (notificationPreferences["granted"] === false) {
return false;
}
return true;
}
const scheduleChoreNotification = async (chores, userProfile,allPerformers) => {
// for each chore will create local notification:
const notifications = [];
const now = new Date()
const devicePreferences = await getNotificationPreferences();
for (let i = 0; i < chores.length; i++) {
const chore = chores[i];
const chorePreferences = JSON.parse(chore.notificationMetadata)
if ( chore.notification ===false || chore.nextDueDate === null) {
continue;
}
scheduleDueNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications)
schedulePreDueNotification(chore, userProfile, allPerformers,chorePreferences, devicePreferences,notifications)
scheduleNaggingNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications)
}
LocalNotifications.schedule({
notifications,
});
}
const scheduleDueNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
if (devicePreferences['dueNotification'] !== true || chorePreferences['dueDate'] !== true){
return
}
const nextDueDate = new Date(chore.nextDueDate)
const diff = nextDueDate - now
if (diff < 0) {
return
}
const notification = {
title: `${chore.name} is due! 🕒`,
body: userProfile.id === chore.assignedTo ? `It's assigned to you!` : `It is ${allPerformers[chore.assignedTo].name}'s turn`,
id: chore.id,
allowWhileIdle: true,
schedule: {
at: new Date(chore.nextDueDate),
},
extra: {
choreId: chore.id,
},
};
notifications.push(notification);
}
const schedulePreDueNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
if (devicePreferences['preDueNotification'] !== true || chorePreferences['preDue'] !== true){
return
}
const nextDueDate = new Date(chore.nextDueDate)
const diff = nextDueDate - now
if (diff < 0 || userProfile.id !== chore.assignedTo) {
return
}
const notification = {
title: `${chore.name} is due soon! 🕒`,
body: `is due at ${nextDueDate.toLocaleTimeString()}`,
id: chore.id,
allowWhileIdle: true,
schedule: {
// 1 hour before
at: new Date(nextDueDate - 60 * 60 * 1000),
},
extra: {
choreId: chore.id,
},
};
notifications.push(notification);
}
const scheduleNaggingNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
if (devicePreferences['naggingNotification'] === false || chorePreferences.nagging !== true){
return
}
const nextDueDate = new Date(chore.nextDueDate)
const diff = nextDueDate - now
if (diff > 0 || userProfile.id !== chore.assignedTo) {
return
}
const notification = {
title: `${chore.name} is overdue! 🕒`,
body: `❗ It was due at ${nextDueDate.toLocaleTimeString()}`,
id: chore.id,
allowWhileIdle: true,
schedule: {
at: new Date(chore.nextDueDate),
},
extra: {
choreId: chore.id,
},
};
notifications.push(notification);
}
export{ scheduleChoreNotification, canScheduleNotification }

View file

@ -34,6 +34,7 @@ import { useChores } from '../../queries/ChoreQueries'
import {
GetAllUsers,
GetArchivedChores,
GetChores,
GetUserProfile,
} from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
@ -42,6 +43,9 @@ import { useLabels } from '../Labels/LabelQueries'
import ChoreCard from './ChoreCard'
import IconButtonWithMenu from './IconButtonWithMenu'
import { canScheduleNotification, scheduleChoreNotification } from './LocalNotificationScheduler'
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
const MyChores = () => {
const { userProfile, setUserProfile } = useContext(UserContext)
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
@ -54,7 +58,6 @@ const MyChores = () => {
const [selectedChoreSection, setSelectedChoreSection] = useState('due_date')
const [openChoreSections, setOpenChoreSections] = useState({})
const [searchTerm, setSearchTerm] = useState('')
const [activeUserId, setActiveUserId] = useState(0)
const [performers, setPerformers] = useState([])
const [anchorEl, setAnchorEl] = useState(null)
const menuRef = useRef(null)
@ -211,26 +214,51 @@ const MyChores = () => {
}
return groups
}
useEffect(() => {
if (userProfile === null) {
GetUserProfile()
.then(response => response.json())
.then(data => {
setUserProfile(data.res)
})
}
GetAllUsers()
.then(response => response.json())
.then(data => {
setPerformers(data.res)
})
Promise.all([GetChores(), GetAllUsers(),GetUserProfile()]).then(responses => {
const [choresResponse, usersResponse, userProfileResponse] = responses;
if (!choresResponse.ok) {
throw new Error(choresResponse.statusText);
}
if (!usersResponse.ok) {
throw new Error(usersResponse.statusText);
}
if (!userProfileResponse.ok) {
throw new Error(userProfileResponse.statusText);
}
Promise.all([choresResponse.json(), usersResponse.json(), userProfileResponse.json()]).then(data => {
const [choresData, usersData, userProfileData] = data;
setUserProfile(userProfileData.res);
choresData.res.sort(choreSorter);
setChores(choresData.res);
setFilteredChores(choresData.res);
setPerformers(usersData.res);
if (canScheduleNotification()) {
scheduleChoreNotification(choresData.res, userProfileData.res, usersData.res);
}
});
const currentUser = JSON.parse(localStorage.getItem('user'))
if (currentUser !== null) {
setActiveUserId(currentUser.id)
}
})
// GetAllUsers()
// .then(response => response.json())
// .then(data => {
// setPerformers(data.res)
// })
// GetUserProfile().then(response => response.json()).then(data => {
// setUserProfile(data.res)
// })
// const currentUser = JSON.parse(localStorage.getItem('user'))
// if (currentUser !== null) {
// setActiveUserId(currentUser.id)
// }
}, [])
useEffect(() => {
@ -391,13 +419,13 @@ const MyChores = () => {
setSearchTerm(term)
setFilteredChores(fuse.search(term).map(result => result.item))
}
if (
userProfile === null ||
userLabelsLoading ||
performers.length === 0 ||
choresLoading
) {
) {
return <LoadingComponent />
}
@ -802,6 +830,7 @@ const MyChores = () => {
>
<Typography level='title-md'>{snackBarMessage}</Typography>
</Snackbar>
<NotificationAccessSnackbar />
</Container>
)
}

View file

@ -0,0 +1,87 @@
import { Capacitor } from '@capacitor/core';
import { Button, Snackbar, Stack, Typography } from '@mui/joy'
import { Preferences } from '@capacitor/preferences';
import { LocalNotifications } from '@capacitor/local-notifications';
import {React, useEffect, useState} from 'react';
const NotificationAccessSnackbar = () => {
const [open, setOpen] = useState(false);
if (!Capacitor.isNativePlatform()) {
return null;
}
const getNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'notificationPreferences' });
return JSON.parse(ret.value);
};
useEffect(() => {
getNotificationPreferences().then((data) => {
// if optOut is true then don't show the snackbar
if(data?.optOut === true || data?.granted === true) {
return;
}
setOpen(true);
});
}
, []);
return (
<Snackbar
// autoHideDuration={5000}
variant="solid"
color="primary"
size="lg"
invertedColors
open={open}
onClose={() => setOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={(theme) => ({
background: `linear-gradient(45deg, ${theme.palette.primary[600]} 30%, ${theme.palette.primary[500]} 90%})`,
maxWidth: 360,
})}
>
<div>
<Typography level="title-lg">Need Notification?</Typography>
<Typography sx={{ mt: 1, mb: 2 }}>
You need to enable permission to receive notifications, do you want to enable it?
</Typography>
<Stack direction="row" spacing={1}>
<Button variant="solid" color="primary" onClick={() => {
const notificationPreferences = { optOut: false };
LocalNotifications.requestPermissions().then((resp) => {
if (resp.display === 'granted') {
notificationPreferences['granted'] = true;
}
})
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
setOpen(false);
}}>
Yes
</Button>
<Button
variant="outlined"
color="primary"
onClick={() => {
const notificationPreferences = { optOut: true };
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
setOpen(false);
}}
>
No, Keep it Disabled
</Button>
</Stack>
</div>
</Snackbar>
)
}
export default NotificationAccessSnackbar;

View file

@ -27,7 +27,7 @@ import moment from 'moment'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { API_URL } from '../Config'
import { GetAllUsers } from '../utils/Fetcher'
import { GetAllUsers, GetChores, MarkChoreComplete } from '../utils/Fetcher'
import { Fetch } from '../utils/TokenManager'
import DateModal from './Modals/Inputs/DateModal'
// import moment from 'moment'
@ -98,7 +98,7 @@ const ChoresOverview = () => {
}
useEffect(() => {
// fetch chores:
Fetch(`${API_URL}/chores/`)
GetChores()
.then(response => response.json())
.then(data => {
const filteredData = data.res.filter(
@ -263,9 +263,8 @@ const ChoresOverview = () => {
size='sm'
// sx={{ borderRadius: '50%' }}
onClick={() => {
Fetch(`${API_URL}/chores/${chore.id}/do`, {
method: 'POST',
}).then(response => {
MarkChoreComplete(chore.id,null,null,null)
.then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
@ -326,14 +325,8 @@ const ChoresOverview = () => {
alert('Please select a performer')
return
}
fetch(
`${API_URL}/chores/${choreId}/do?performer=${activeUserId}&completedDate=${new Date(
date,
).toISOString()}`,
{
method: 'POST',
},
).then(response => {
MarkChoreComplete(choreId, null, date, activeUserId)
.then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res

View file

@ -18,6 +18,7 @@ import { API_URL } from '../../Config'
import {
DeleteChoreHistory,
GetAllCircleMembers,
GetChoreHistory,
UpdateChoreHistory,
} from '../../utils/Fetcher'
import { Fetch } from '../../utils/TokenManager'
@ -40,7 +41,7 @@ const ChoreHistory = () => {
setIsLoading(true) // Start loading
Promise.all([
Fetch(`${API_URL}/chores/${choreId}/history`).then(res => res.json()),
GetChoreHistory(choreId).then(res => res.json()),
GetAllCircleMembers().then(res => res.json()),
])
.then(([historyData, usersData]) => {

View file

@ -1,10 +1,74 @@
import { Button, Divider, Input, Option, Select, Typography } from '@mui/joy'
import { useContext, useState } from 'react'
import { Box, Button, Card, Checkbox, Divider, FormControl, FormHelperText, FormLabel, IconButton, Input, List, ListItem, Option, Select, Snackbar, Switch, Typography } from '@mui/joy'
import React, { useContext, useEffect, useState } from 'react'
import { UserContext } from '../../contexts/UserContext'
import { GetUserProfile, UpdateUserDetails } from '../../utils/Fetcher'
import { Capacitor } from '@capacitor/core'
import { Preferences } from '@capacitor/preferences'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Close } from '@mui/icons-material'
import { PushNotifications } from '@capacitor/push-notifications'
import { UpdateNotificationTarget } from '../../utils/Fetcher'
const NotificationSetting = () => {
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
const { userProfile, setUserProfile } = useContext(UserContext)
useEffect(() => {
if (!userProfile) {
GetUserProfile().then(resp => {
resp.json().then(data => {
setUserProfile(data.res)
setChatID(data.res.chatID)
})
})
}
}, [])
const getNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'notificationPreferences' });
return JSON.parse(ret.value);
};
const setNotificationPreferences = async (value) => {
if (value.granted === false){
await Preferences.set({ key: 'notificationPreferences', value: JSON.stringify({ granted: false }) });
return
}
const currentSettings = await getNotificationPreferences();
await Preferences.set({ key: 'notificationPreferences', value: JSON.stringify({ ...currentSettings, ...value }) });
};
const getPushNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'pushNotificationPreferences' });
return JSON.parse(ret.value);
};
const setPushNotificationPreferences = async (value) => {
await Preferences.set({ key: 'pushNotificationPreferences', value: JSON.stringify(value) });
};
const [deviceNotification, setDeviceNotification] = useState(
false
)
const [dueNotification, setDueNotification] = useState(true)
const [preDueNotification, setPreDueNotification] = useState(false)
const [naggingNotification, setNaggingNotification] = useState(false)
const [pushNotification, setPushNotification] = useState(
false
)
useEffect(() => {
getNotificationPreferences().then((resp) => {
setDeviceNotification(resp.granted)
setDueNotification(resp.dueNotification)
setPreDueNotification(resp.preDueNotification)
setNaggingNotification(resp.naggingNotification)
}
)
getPushNotificationPreferences().then((resp) => {
setPushNotification(resp.granted)
}
)
}, [])
const [notificationTarget, setNotificationTarget] = useState(
userProfile?.notification_target
? String(userProfile.notification_target.type)
@ -57,10 +121,236 @@ const NotificationSetting = () => {
}
return (
<div className='grid gap-4 py-4' id='notifications'>
<Typography level='h3'>Notification Settings</Typography>
<Typography level='h3'>Device Notification</Typography>
<Divider />
<Typography level='body-md'>Manage your notification settings</Typography>
<Typography level='body-md'>Manage your Device Notificaiton</Typography>
<FormControl
orientation="horizontal"
sx={{ width: 400, justifyContent: 'space-between' }}
>
<div>
<FormLabel>Device Notification</FormLabel>
<FormHelperText sx={{ mt: 0 }}>{Capacitor.isNativePlatform()? 'Receive notification on your device when a task is due' : 'This feature is only available on mobile devices'} </FormHelperText>
</div>
<Switch
disabled={!Capacitor.isNativePlatform()}
checked={deviceNotification}
onClick={(event) =>{
event.preventDefault()
if (deviceNotification === false){
LocalNotifications.requestPermissions().then((resp) => {
if (resp.display === 'granted') {
setDeviceNotification(true)
setNotificationPreferences({granted: true})
}
else if (resp.display === 'denied') {
setIsSnackbarOpen(true)
setDeviceNotification(false)
setNotificationPreferences({granted: false})
}
})
}
else{
setDeviceNotification(false)
}
}
}
color={deviceNotification ? 'success' : 'neutral'}
variant={deviceNotification ? 'solid' : 'outlined'}
endDecorator={deviceNotification ? 'On' : 'Off'}
slotProps={{
endDecorator: {
sx: {
minWidth: 24,
},
},
}}
/>
</FormControl>
{deviceNotification && (
<Card>
{[
{
'title': 'Due Date Notification',
'checked': dueNotification,
'set': setDueNotification,
'label': 'Notification when the task is due',
'property': 'dueNotification',
'disabled': false
},
{
'title': 'Pre-Due Date Notification',
'checked': preDueNotification,
'set': setPreDueNotification,
'label': 'Notification a few hours before the task is due',
'property': 'preDueNotification',
'disabled': true
},
{
'title': 'Overdue Notification',
'checked': naggingNotification,
'set': setNaggingNotification,
'label': 'Notification when the task is overdue',
'property': 'naggingNotification',
'disabled': true
}
]
.map(item => (
<FormControl
orientation="horizontal"
sx={{ width: 385, justifyContent: 'space-between' }}
>
<div>
<FormLabel>{item.title}</FormLabel>
<FormHelperText sx={{ mt: 0 }}>{item.label} </FormHelperText>
</div>
<Switch checked={item.checked}
disabled={item.disabled}
onClick={() =>{
setNotificationPreferences({[item.property]: !item.checked})
item.set(!item.checked)
}}
color={item.checked ? 'success' : ''}
variant='solid' endDecorator={item.checked ? 'On' : 'Off'} slotProps={{ endDecorator: { sx: { minWidth: 24 } } }} />
</FormControl>
))}
</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})
}
if (resp.receive!== 'granted') {
setIsSnackbarOpen(true)
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,
},
},
}}
/>
</FormControl> */}
<Button
variant='soft'
color='primary'
sx={{
width: '210px',
mb: 1,
}}
onClick={() => {
// schedule a local notification in 5 seconds
LocalNotifications.schedule({
notifications: [
{
title: 'Task Reminder',
body: 'You have a task due soon',
id: 1,
schedule: { at: new Date(Date.now() + 3000) },
sound: null,
attachments: null,
actionTypeId: '',
extra: null,
},
],
});
}
}>Test Notification </Button>
<Typography level='h3'>Custom Notification</Typography>
<Divider />
<Typography level='body-md'>Notificaiton through other platform like Telegram or Pushover</Typography>
<FormControl
orientation="horizontal"
sx={{ width: 400, justifyContent: 'space-between' }}
>
<div>
<FormLabel>Custom Notification</FormLabel>
<FormHelperText sx={{ mt: 0 }}>Receive notification on other platform</FormHelperText>
</div>
<Switch
checked={chatID !== 0}
onClick={(event) =>{
event.preventDefault()
if (chatID !== 0){
setChatID(0)
}
else{
setChatID('')
UpdateUserDetails({
chatID: Number(0),
}).then(resp => {
resp.json().then(data => {
setUserProfile(data)
})
})
}
setNotificationTarget('0')
handleSave()
}
}
color={chatID!==0 ? 'success' : 'neutral'}
variant={chatID!==0 ? 'solid' : 'outlined'}
endDecorator={chatID!==0 ? 'On' : 'Off'}
slotProps={{
endDecorator: {
sx: {
minWidth: 24,
},
},
}}
/>
</FormControl>
{chatID !== 0&& (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<Select
value={notificationTarget}
sx={{ maxWidth: '200px' }}
@ -141,6 +431,16 @@ const NotificationSetting = () => {
>
Save
</Button>
</Box>
)}
<Snackbar open={isSnackbarOpen} autoHideDuration={8000} onClose={() => setIsSnackbarOpen(false)} endDecorator={<IconButton size='md' onClick={() => setIsSnackbarOpen(false)}><Close/></IconButton>}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Typography level='title-md'>Permission Denied</Typography>
<Typography level='body-md'>
You have denied the permission to receive notification on this device. Please enable it in your device settings
</Typography>
</div>
</Snackbar>
</div>
)
}

View file

@ -120,6 +120,7 @@ const NavBar = () => {
tick
</span>
</Typography>
</Box>
<Drawer
open={drawerOpen}