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" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
<provider <provider

View File

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

View File

@@ -1,10 +1,44 @@
import { App as mobileApp } from '@capacitor/app' import { App as mobileApp } from '@capacitor/app'
import { Browser } from '@capacitor/browser'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { Device } from '@capacitor/device' import { Device } from '@capacitor/device'
import { LocalNotifications } from '@capacitor/local-notifications' import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences' import { Preferences } from '@capacitor/preferences'
import { PushNotifications } from '@capacitor/push-notifications' import { PushNotifications } from '@capacitor/push-notifications'
import { RegisterDeviceToken } from './utils/Fetcher' 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 = () => { const localNotificationListenerRegistration = () => {
LocalNotifications.addListener('localNotificationReceived', notification => { LocalNotifications.addListener('localNotificationReceived', notification => {
console.log('Notification received', notification) console.log('Notification received', notification)
@@ -180,6 +214,17 @@ const registerCapacitorListeners = () => {
return return
} }
localNotificationListenerRegistration() 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 }) => { mobileApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) { if (canGoBack) {
window.history.back() window.history.back()

View File

@@ -10,7 +10,6 @@ import Settings from '@/views/Settings/Settings'
import SettingsOverview from '@/views/Settings/SettingsOverview' import SettingsOverview from '@/views/Settings/SettingsOverview'
import SettingsRoutes from '@/views/Settings/SettingsRoutes' import SettingsRoutes from '@/views/Settings/SettingsRoutes'
import ThemeSettings from '@/views/Settings/ThemeSettings' import ThemeSettings from '@/views/Settings/ThemeSettings'
import { Capacitor } from '@capacitor/core'
import { RouterProvider, createBrowserRouter } from 'react-router-dom' import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import AuthenticationLoading from '../views/Authorization/Authenticating' import AuthenticationLoading from '../views/Authorization/Authenticating'
import ForgotPasswordView from '../views/Authorization/ForgotPasswordView' import ForgotPasswordView from '../views/Authorization/ForgotPasswordView'
@@ -46,8 +45,9 @@ import UserActivities from '../views/User/UserActivities'
import UserPoints from '../views/User/UserPoints' import UserPoints from '../views/User/UserPoints'
const getMainRoute = () => { const getMainRoute = () => {
if ( if (
import.meta.env.VITE_IS_LANDING_DEFAULT === 'true' && // if domain is www.donetick.com or donetick.com then show landing page:
!Capacitor.isNativePlatform() window.location.hostname === 'www.donetick.com' ||
window.location.hostname === 'donetick.com'
) { ) {
return <Landing /> return <Landing />
} }
@@ -183,7 +183,7 @@ const Router = createBrowserRouter([
element: <AuthenticationLoading />, element: <AuthenticationLoading />,
}, },
{ {
path: '/landing', path: '/welcome',
element: <Landing />, element: <Landing />,
}, },
{ {

View File

@@ -1,12 +1,12 @@
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy' import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Logo from '../../Logo' import Logo from '../../Logo'
import { apiClient } from '../../utils/ApiClient'
import Cookies from 'js-cookie' import Cookies from 'js-cookie'
import { useRef } from 'react' import { useRef } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries' import { useUserProfile } from '../../queries/UserQueries'
import { apiClient } from '../../utils/ApiClient'
import { GetUserProfile } from '../../utils/Fetcher' import { GetUserProfile } from '../../utils/Fetcher'
const AuthenticationLoading = () => { const AuthenticationLoading = () => {
@@ -58,8 +58,9 @@ const AuthenticationLoading = () => {
} }
if (code) { if (code) {
const baseURL = apiClient.baseURL const baseURL = apiClient.getApiURL()
fetch(`${baseURL}/auth/${provider}/callback`, {
fetch(`${baseURL}/auth/oauth2/callback`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -67,6 +68,7 @@ const AuthenticationLoading = () => {
body: JSON.stringify({ body: JSON.stringify({
code, code,
state: returnedState, state: returnedState,
redirect_uri: `${window.location.origin}/auth/oauth2`,
}), }),
}).then(response => { }).then(response => {
if (response.status === 200) { if (response.status === 200) {

View File

@@ -182,11 +182,19 @@ const LoginView = () => {
} }
try { try {
const response = await apiClient.post(`/auth/${provider}/callback`, { const response = await apiClient.post(
provider: provider, `/auth/${provider}/callback`,
token: getAccessToken(data), JSON.stringify({
data: data, provider: provider,
}) token: getAccessToken(data),
data: data,
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
if (response.ok) { if (response.ok) {
const responseData = await response.json() const responseData = await response.json()

View File

@@ -1,7 +1,6 @@
import { import {
Archive, Archive,
CalendarMonth, CalendarMonth,
CancelScheduleSend,
Check, Check,
Checklist, Checklist,
CloseFullscreen, CloseFullscreen,
@@ -35,7 +34,6 @@ import {
MenuButton, MenuButton,
MenuItem, MenuItem,
Sheet, Sheet,
Snackbar,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { Divider } from '@mui/material' import { Divider } from '@mui/material'
@@ -54,6 +52,7 @@ import {
useStartChore, useStartChore,
} from '../../queries/TimeQueries' } from '../../queries/TimeQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx' import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { import {
@@ -63,6 +62,7 @@ import {
RejectChore, RejectChore,
SkipChore, SkipChore,
UnArchiveChore, UnArchiveChore,
UndoChoreAction,
UpdateChorePriority, UpdateChorePriority,
} from '../../utils/Fetcher' } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities' import Priorities from '../../utils/Priorities'
@@ -82,12 +82,11 @@ const ChoreView = () => {
const { choreId } = useParams() const { choreId } = useParams()
const [note, setNote] = useState(null) const [note, setNote] = useState(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccess, showError, showUndo } = useNotification()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const [isPendingCompletion, setIsPendingCompletion] = useState(false)
const [timeoutId, setTimeoutId] = useState(null) const [timeoutId, setTimeoutId] = useState(null)
const [secondsLeftToCancel, setSecondsLeftToCancel] = useState(null)
const [completedDate, setCompletedDate] = useState(null) const [completedDate, setCompletedDate] = useState(null)
const [confirmModelConfig, setConfirmModelConfig] = useState({ const [confirmModelConfig, setConfirmModelConfig] = useState({
isOpen: false, isOpen: false,
@@ -189,20 +188,7 @@ const ChoreView = () => {
setInfoCards(cards) setInfoCards(cards)
} }
const handleTaskCompletion = () => { const handleTaskCompletion = () => {
setIsPendingCompletion(true) let id = setTimeout(() => {
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(() => {
MarkChoreComplete( MarkChoreComplete(
choreId, choreId,
impersonatedUser impersonatedUser
@@ -220,11 +206,8 @@ const ChoreView = () => {
} }
}) })
.then(() => { .then(() => {
setIsPendingCompletion(false)
clearTimeout(id) clearTimeout(id)
clearInterval(countdownInterval) // Ensure to clear this interval as well
setTimeoutId(null) setTimeoutId(null)
setSecondsLeftToCancel(null)
// Invalidate chores cache to refetch data // Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['chores'])
}) })
@@ -241,6 +224,16 @@ const ChoreView = () => {
}, 3000) }, 3000)
setTimeoutId(id) setTimeoutId(id)
// Show undo notification
showSuccess({
title: 'Task Completed',
message: 'Your task has been marked as complete',
undoAction: () => {
clearTimeout(id)
setTimeoutId(null)
},
})
} }
const handleSkippingTask = () => { const handleSkippingTask = () => {
SkipChore(choreId).then(response => { SkipChore(choreId).then(response => {
@@ -250,6 +243,36 @@ const ChoreView = () => {
setChore(newChore) setChore(newChore)
// Invalidate chores cache to refetch data // Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores']) 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' size='lg'
onClick={handleTaskCompletion} onClick={handleTaskCompletion}
disabled={ disabled={
isPendingCompletion ||
notInCompletionWindow(chore) || notInCompletionWindow(chore) ||
(chore.lastCompletedDate !== null && (chore.lastCompletedDate !== null &&
chore.frequencyType === 'once') chore.frequencyType === 'once')
} }
color={isPendingCompletion ? 'danger' : 'success'} color='success'
startDecorator={<Check />} startDecorator={<Check />}
sx={{ sx={{
flex: 4, flex: 4,
@@ -1017,31 +1039,6 @@ const ChoreView = () => {
</Box> </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={confirmModelConfig} />
<ConfirmationModal config={timerActionConfig} /> <ConfirmationModal config={timerActionConfig} />
</Card> </Card>

View File

@@ -3,6 +3,8 @@ import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences' import { Preferences } from '@capacitor/preferences'
import murmurhash from 'murmurhash' import murmurhash from 'murmurhash'
const MAX_LOCAL_NOTIFICATIONS = 64
const getNotificationPreferences = async () => { const getNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'notificationPreferences' }) const ret = await Preferences.get({ key: 'notificationPreferences' })
return JSON.parse(ret.value) return JSON.parse(ret.value)
@@ -206,6 +208,13 @@ const scheduleChoreNotification = async (
continue 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({ LocalNotifications.schedule({
notifications, notifications,

View File

@@ -1,17 +1,17 @@
import { useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query' 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 { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
import { import {
ApproveChore, ApproveChore,
DeleteChore, DeleteChore,
MarkChoreComplete, MarkChoreComplete,
NudgeChore, NudgeChore,
RejectChore, RejectChore,
SkipChore, SkipChore,
UndoChoreAction, UndoChoreAction,
UpdateChoreAssignee, UpdateChoreAssignee,
UpdateDueDate, UpdateDueDate,
} from '../../../utils/Fetcher' } from '../../../utils/Fetcher'
export const useChoreActions = ({ export const useChoreActions = ({
@@ -682,6 +682,23 @@ export const useChoreActions = ({
showSuccess({ showSuccess({
title: '⏭️ Tasks Skipped', title: '⏭️ Tasks Skipped',
message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`, 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({}) setConfirmModelConfig({})
}, },
}) })
}, [getSelectedChoresData, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig]) }, [getSelectedChoresData, showSuccess, showError, showUndo, refetchChores, clearSelection, setConfirmModelConfig])
return { return {
handleChoreAction, handleChoreAction,