Merge branch 'dev'

This commit is contained in:
Mo Tarbin
2026-02-09 23:25:34 -05:00
9 changed files with 165 additions and 73 deletions

View File

@@ -19,6 +19,14 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link intent filter for OAuth callback -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="donetick" />
</intent-filter>
</activity>
<provider

View File

@@ -42,10 +42,16 @@
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string></string>
<string>com.donetick.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>donetick</string>
</array>
</dict>
</array>
<key>NSCameraUsageDescription</key>

View File

@@ -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()

View File

@@ -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 <Landing />
}
@@ -183,7 +183,7 @@ const Router = createBrowserRouter([
element: <AuthenticationLoading />,
},
{
path: '/landing',
path: '/welcome',
element: <Landing />,
},
{

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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={<Check />}
sx={{
flex: 4,
@@ -1017,31 +1039,6 @@ const ChoreView = () => {
</Box>
)}
<Snackbar
open={isPendingCompletion}
endDecorator={
<Button
onClick={() => {
if (timeoutId) {
clearTimeout(timeoutId)
setIsPendingCompletion(false)
setTimeoutId(null)
setSecondsLeftToCancel(null) // Reset or adjust as needed
}
}}
size='lg'
variant='outlined'
color='danger'
startDecorator={<CancelScheduleSend />}
>
Cancel
</Button>
}
>
<Typography level='body-md' textAlign={'center'}>
Task will be marked as completed in {secondsLeftToCancel} seconds
</Typography>
</Snackbar>
<ConfirmationModal config={confirmModelConfig} />
<ConfirmationModal config={timerActionConfig} />
</Card>

View File

@@ -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,

View File

@@ -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,