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,