diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 13e1d3e..837d403 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -19,6 +19,14 @@
+
+
+
+
+
+
+
+
UIInterfaceOrientationLandscapeRight
UIViewControllerBasedStatusBarAppearance
+
+ CFBundleURLTypes
CFBundleURLName
-
+ com.donetick.app
+ CFBundleURLSchemes
+
+ donetick
+
NSCameraUsageDescription
diff --git a/src/CapacitorListener.js b/src/CapacitorListener.js
index cbb8071..c1facf2 100644
--- a/src/CapacitorListener.js
+++ b/src/CapacitorListener.js
@@ -1,10 +1,44 @@
import { App as mobileApp } from '@capacitor/app'
+import { Browser } from '@capacitor/browser'
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 { RegisterDeviceToken } from './utils/Fetcher'
+
+// OAuth callback handler for deep links
+const handleOAuthDeepLink = async url => {
+ console.log('OAuth deep link received:', url)
+
+ try {
+ // Parse the URL to extract code and state
+ const urlObj = new URL(url)
+ const code = urlObj.searchParams.get('code')
+ const state = urlObj.searchParams.get('state')
+
+ if (code && state) {
+ // Store the OAuth params for the app to pick up
+ await Preferences.set({
+ key: 'oauth_callback',
+ value: JSON.stringify({ code, state, timestamp: Date.now() }),
+ })
+
+ // Close the browser if it's still open
+ try {
+ await Browser.close()
+ } catch (e) {
+ // Browser might already be closed
+ }
+
+ // Navigate to the OAuth handler page
+ window.location.href = `/auth/oauth2?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
+ }
+ } catch (error) {
+ console.error('Error handling OAuth deep link:', error)
+ }
+}
+
const localNotificationListenerRegistration = () => {
LocalNotifications.addListener('localNotificationReceived', notification => {
console.log('Notification received', notification)
@@ -180,6 +214,17 @@ const registerCapacitorListeners = () => {
return
}
localNotificationListenerRegistration()
+
+ // Register deep link handler for OAuth and other deep links
+ mobileApp.addListener('appUrlOpen', event => {
+ console.log('App URL opened:', event.url)
+
+ // Handle OAuth callback
+ if (event.url.startsWith('donetick://auth/')) {
+ handleOAuthDeepLink(event.url)
+ }
+ })
+
mobileApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) {
window.history.back()
diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx
index 51c2208..72c983a 100644
--- a/src/contexts/RouterContext.jsx
+++ b/src/contexts/RouterContext.jsx
@@ -10,7 +10,6 @@ import Settings from '@/views/Settings/Settings'
import SettingsOverview from '@/views/Settings/SettingsOverview'
import SettingsRoutes from '@/views/Settings/SettingsRoutes'
import ThemeSettings from '@/views/Settings/ThemeSettings'
-import { Capacitor } from '@capacitor/core'
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import AuthenticationLoading from '../views/Authorization/Authenticating'
import ForgotPasswordView from '../views/Authorization/ForgotPasswordView'
@@ -46,8 +45,9 @@ import UserActivities from '../views/User/UserActivities'
import UserPoints from '../views/User/UserPoints'
const getMainRoute = () => {
if (
- import.meta.env.VITE_IS_LANDING_DEFAULT === 'true' &&
- !Capacitor.isNativePlatform()
+ // if domain is www.donetick.com or donetick.com then show landing page:
+ window.location.hostname === 'www.donetick.com' ||
+ window.location.hostname === 'donetick.com'
) {
return
}
@@ -183,7 +183,7 @@ const Router = createBrowserRouter([
element: ,
},
{
- path: '/landing',
+ path: '/welcome',
element: ,
},
{
diff --git a/src/views/Authorization/Authenticating.jsx b/src/views/Authorization/Authenticating.jsx
index 00c9e36..e811b45 100644
--- a/src/views/Authorization/Authenticating.jsx
+++ b/src/views/Authorization/Authenticating.jsx
@@ -1,12 +1,12 @@
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import Logo from '../../Logo'
-import { apiClient } from '../../utils/ApiClient'
import Cookies from 'js-cookie'
import { useRef } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
+import { apiClient } from '../../utils/ApiClient'
import { GetUserProfile } from '../../utils/Fetcher'
const AuthenticationLoading = () => {
@@ -58,8 +58,9 @@ const AuthenticationLoading = () => {
}
if (code) {
- const baseURL = apiClient.baseURL
- fetch(`${baseURL}/auth/${provider}/callback`, {
+ const baseURL = apiClient.getApiURL()
+
+ fetch(`${baseURL}/auth/oauth2/callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -67,6 +68,7 @@ const AuthenticationLoading = () => {
body: JSON.stringify({
code,
state: returnedState,
+ redirect_uri: `${window.location.origin}/auth/oauth2`,
}),
}).then(response => {
if (response.status === 200) {
diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx
index aabae5c..f0a1467 100644
--- a/src/views/Authorization/LoginView.jsx
+++ b/src/views/Authorization/LoginView.jsx
@@ -182,11 +182,19 @@ const LoginView = () => {
}
try {
- const response = await apiClient.post(`/auth/${provider}/callback`, {
- provider: provider,
- token: getAccessToken(data),
- data: data,
- })
+ const response = await apiClient.post(
+ `/auth/${provider}/callback`,
+ JSON.stringify({
+ provider: provider,
+ token: getAccessToken(data),
+ data: data,
+ }),
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ )
if (response.ok) {
const responseData = await response.json()
diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx
index 0a003c5..5cd7ee6 100644
--- a/src/views/ChoreEdit/ChoreView.jsx
+++ b/src/views/ChoreEdit/ChoreView.jsx
@@ -1,7 +1,6 @@
import {
Archive,
CalendarMonth,
- CancelScheduleSend,
Check,
Checklist,
CloseFullscreen,
@@ -35,7 +34,6 @@ import {
MenuButton,
MenuItem,
Sheet,
- Snackbar,
Typography,
} from '@mui/joy'
import { Divider } from '@mui/material'
@@ -54,6 +52,7 @@ import {
useStartChore,
} from '../../queries/TimeQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
+import { useNotification } from '../../service/NotificationProvider'
import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import {
@@ -63,6 +62,7 @@ import {
RejectChore,
SkipChore,
UnArchiveChore,
+ UndoChoreAction,
UpdateChorePriority,
} from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
@@ -82,12 +82,11 @@ const ChoreView = () => {
const { choreId } = useParams()
const [note, setNote] = useState(null)
const queryClient = useQueryClient()
+ const { showSuccess, showError, showUndo } = useNotification()
const [searchParams] = useSearchParams()
- const [isPendingCompletion, setIsPendingCompletion] = useState(false)
const [timeoutId, setTimeoutId] = useState(null)
- const [secondsLeftToCancel, setSecondsLeftToCancel] = useState(null)
const [completedDate, setCompletedDate] = useState(null)
const [confirmModelConfig, setConfirmModelConfig] = useState({
isOpen: false,
@@ -189,20 +188,7 @@ const ChoreView = () => {
setInfoCards(cards)
}
const handleTaskCompletion = () => {
- setIsPendingCompletion(true)
- let seconds = 3 // Starting countdown from 3 seconds
- setSecondsLeftToCancel(seconds)
-
- const countdownInterval = setInterval(() => {
- seconds -= 1
- setSecondsLeftToCancel(seconds)
-
- if (seconds <= 0) {
- clearInterval(countdownInterval) // Stop the countdown when it reaches 0
- }
- }, 1000)
-
- const id = setTimeout(() => {
+ let id = setTimeout(() => {
MarkChoreComplete(
choreId,
impersonatedUser
@@ -220,11 +206,8 @@ const ChoreView = () => {
}
})
.then(() => {
- setIsPendingCompletion(false)
clearTimeout(id)
- clearInterval(countdownInterval) // Ensure to clear this interval as well
setTimeoutId(null)
- setSecondsLeftToCancel(null)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
@@ -241,6 +224,16 @@ const ChoreView = () => {
}, 3000)
setTimeoutId(id)
+
+ // Show undo notification
+ showSuccess({
+ title: 'Task Completed',
+ message: 'Your task has been marked as complete',
+ undoAction: () => {
+ clearTimeout(id)
+ setTimeoutId(null)
+ },
+ })
}
const handleSkippingTask = () => {
SkipChore(choreId).then(response => {
@@ -250,6 +243,36 @@ const ChoreView = () => {
setChore(newChore)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
+
+ // Show undo notification
+ showSuccess({
+ message: 'Task skipped',
+ undoAction: async () => {
+ try {
+ const undoResponse = await UndoChoreAction(choreId)
+ if (undoResponse.ok) {
+ // Refetch chore details after undo
+ const detailResponse = await GetChoreDetailById(choreId)
+ if (detailResponse.ok) {
+ const detailData = await detailResponse.json()
+ setChore(detailData.res)
+ queryClient.invalidateQueries(['chores'])
+ }
+ showUndo({
+ title: 'Undo Successful',
+ message: 'Task skip has been undone.',
+ })
+ } else {
+ throw new Error('Failed to undo')
+ }
+ } catch (error) {
+ showError({
+ title: 'Undo Failed',
+ message: 'Unable to undo the action. Please try again.',
+ })
+ }
+ },
+ })
})
}
})
@@ -924,12 +947,11 @@ const ChoreView = () => {
size='lg'
onClick={handleTaskCompletion}
disabled={
- isPendingCompletion ||
notInCompletionWindow(chore) ||
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
}
- color={isPendingCompletion ? 'danger' : 'success'}
+ color='success'
startDecorator={}
sx={{
flex: 4,
@@ -1017,31 +1039,6 @@ const ChoreView = () => {
)}
- {
- if (timeoutId) {
- clearTimeout(timeoutId)
- setIsPendingCompletion(false)
- setTimeoutId(null)
- setSecondsLeftToCancel(null) // Reset or adjust as needed
- }
- }}
- size='lg'
- variant='outlined'
- color='danger'
- startDecorator={}
- >
- Cancel
-
- }
- >
-
- Task will be marked as completed in {secondsLeftToCancel} seconds
-
-
diff --git a/src/views/Chores/LocalNotificationScheduler.js b/src/views/Chores/LocalNotificationScheduler.js
index 12e6b89..7ca1968 100644
--- a/src/views/Chores/LocalNotificationScheduler.js
+++ b/src/views/Chores/LocalNotificationScheduler.js
@@ -3,6 +3,8 @@ import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
import murmurhash from 'murmurhash'
+const MAX_LOCAL_NOTIFICATIONS = 64
+
const getNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'notificationPreferences' })
return JSON.parse(ret.value)
@@ -206,6 +208,13 @@ const scheduleChoreNotification = async (
continue
}
}
+ // sort from soonest to latest:
+ notifications.sort((a, b) => a.schedule.at - b.schedule.at)
+
+ // cap it for 64 notifications for Android:
+ if (notifications.length > MAX_LOCAL_NOTIFICATIONS) {
+ notifications.splice(MAX_LOCAL_NOTIFICATIONS)
+ }
LocalNotifications.schedule({
notifications,
diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js
index 4a55622..abf781f 100644
--- a/src/views/Chores/hooks/useChoreActions.js
+++ b/src/views/Chores/hooks/useChoreActions.js
@@ -1,17 +1,17 @@
-import { useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
-import { useArchiveChore, useDeleteChores } from '../../../queries/ChoreQueries'
+import { useCallback } from 'react'
+import { useArchiveChore } from '../../../queries/ChoreQueries'
import { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
import {
- ApproveChore,
- DeleteChore,
- MarkChoreComplete,
- NudgeChore,
- RejectChore,
- SkipChore,
- UndoChoreAction,
- UpdateChoreAssignee,
- UpdateDueDate,
+ ApproveChore,
+ DeleteChore,
+ MarkChoreComplete,
+ NudgeChore,
+ RejectChore,
+ SkipChore,
+ UndoChoreAction,
+ UpdateChoreAssignee,
+ UpdateDueDate,
} from '../../../utils/Fetcher'
export const useChoreActions = ({
@@ -682,6 +682,23 @@ export const useChoreActions = ({
showSuccess({
title: '⏭️ Tasks Skipped',
message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
+ undoAction: async () => {
+ try {
+ for (const chore of skippedTasks) {
+ await UndoChoreAction(chore.id)
+ }
+ refetchChores()
+ showUndo({
+ title: 'Undo Successful',
+ message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
+ })
+ } catch (error) {
+ showError({
+ title: 'Undo Failed',
+ message: 'Unable to undo the action. Please try again.',
+ })
+ }
+ },
})
}
@@ -704,7 +721,7 @@ export const useChoreActions = ({
setConfirmModelConfig({})
},
})
- }, [getSelectedChoresData, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
+ }, [getSelectedChoresData, showSuccess, showError, showUndo, refetchChores, clearSelection, setConfirmModelConfig])
return {
handleChoreAction,