Add support for iOS and android widgets
This commit is contained in:
@@ -62,7 +62,11 @@ const handleNFCChoreDeepLink = (url, isColdStart) => {
|
||||
|
||||
const handleUrlOpen = (url, isColdStart = false) => {
|
||||
console.log('[NFC] handleUrlOpen:', url)
|
||||
if (url.startsWith('donetick://chores/')) {
|
||||
if (url.startsWith('donetick://chores/add')) {
|
||||
// Widget "+" button: land on the chore list with the quick-add modal open
|
||||
// (MyChores watches for the add_task param and consumes it).
|
||||
routerNavigate('/chores?add_task=1')
|
||||
} else if (url.startsWith('donetick://chores/')) {
|
||||
handleNFCChoreDeepLink(url, isColdStart)
|
||||
} else if (url.startsWith('donetick://auth/')) {
|
||||
handleOAuthDeepLink(url)
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { initWidgetSync } from '../service/WidgetService'
|
||||
|
||||
const QueryContext = ({ children }) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 300000, // 5 minutes
|
||||
gcTime: 600000, // 10 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 300000, // 5 minutes
|
||||
gcTime: 600000, // 10 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
useEffect(() => initWidgetSync(queryClient), [queryClient])
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
|
||||
145
src/service/WidgetService.js
Normal file
145
src/service/WidgetService.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Capacitor, registerPlugin } from '@capacitor/core'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
|
||||
// Native bridge implemented in ios/App/App/WidgetBridgePlugin.swift and
|
||||
// android/.../widget/WidgetBridgePlugin.java. It persists the snapshot in
|
||||
// storage the home-screen widgets can read (App Group defaults on iOS,
|
||||
// SharedPreferences on Android) and asks the OS to redraw them.
|
||||
const WidgetBridge = registerPlugin('WidgetBridge')
|
||||
|
||||
const WINDOW_DAYS = 7
|
||||
const MAX_TASKS = 100
|
||||
const MAX_MEMBERS = 12
|
||||
const PUSH_DEBOUNCE_MS = 1500
|
||||
|
||||
const isNative = () => Capacitor.isNativePlatform()
|
||||
|
||||
// The snapshot carries every circle member's actionable tasks (due inside the
|
||||
// 7-day window, overdue included, or awaiting approval — status 3). Each task
|
||||
// records its assignee so the widgets can filter down to "mine" (the default)
|
||||
// or show everyone, per the user's widget configuration; the People widget
|
||||
// derives per-member counts from the same list. The Today widget re-derives
|
||||
// its subset natively from dueDate so one snapshot feeds all widgets.
|
||||
export const buildWidgetTasks = chores => {
|
||||
const endOfWindow = new Date()
|
||||
endOfWindow.setHours(23, 59, 59, 999)
|
||||
endOfWindow.setDate(endOfWindow.getDate() + WINDOW_DAYS)
|
||||
const cutoff = endOfWindow.getTime()
|
||||
|
||||
return (chores || [])
|
||||
.map(chore => {
|
||||
if (!chore || chore.id == null) return null
|
||||
const approval = chore.status === 3
|
||||
const dueDate = chore.nextDueDate
|
||||
? new Date(chore.nextDueDate).getTime()
|
||||
: null
|
||||
const inWindow = dueDate !== null && dueDate <= cutoff
|
||||
if (!approval && !inWindow) return null
|
||||
return {
|
||||
id: chore.id,
|
||||
name: chore.name || '',
|
||||
dueDate,
|
||||
priority: chore.priority || 0,
|
||||
approval,
|
||||
assignedTo: chore.assignedTo == null ? null : String(chore.assignedTo),
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
if (a.approval !== b.approval) return a.approval ? -1 : 1
|
||||
if (a.dueDate === null) return b.dueDate === null ? 0 : 1
|
||||
if (b.dueDate === null) return -1
|
||||
return a.dueDate - b.dueDate
|
||||
})
|
||||
.slice(0, MAX_TASKS)
|
||||
}
|
||||
|
||||
// Circle members, trimmed to what the widgets render: avatar + short name.
|
||||
export const buildWidgetMembers = members => {
|
||||
return (members || [])
|
||||
.filter(member => member && member.userId != null)
|
||||
.slice(0, MAX_MEMBERS)
|
||||
.map(member => ({
|
||||
id: String(member.userId),
|
||||
name: member.displayName || member.username || '',
|
||||
image: member.image || null,
|
||||
}))
|
||||
}
|
||||
|
||||
const pushSnapshot = async queryClient => {
|
||||
const choresData = queryClient.getQueryData(['chores', false])
|
||||
const chores = choresData?.res
|
||||
if (!Array.isArray(chores)) return
|
||||
|
||||
const profileQuery = queryClient
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey: ['userProfile'] })
|
||||
.find(q => q.state.data?.id != null)
|
||||
const userId = profileQuery?.state.data?.id
|
||||
if (userId == null) return
|
||||
|
||||
const token = apiClient.getToken()
|
||||
if (!token) return
|
||||
|
||||
const members = queryClient.getQueryData(['allCircleMembers'])?.res
|
||||
|
||||
await WidgetBridge.update({
|
||||
data: JSON.stringify({
|
||||
version: 2,
|
||||
lastUpdated: Date.now(),
|
||||
tasks: buildWidgetTasks(chores),
|
||||
members: buildWidgetMembers(members),
|
||||
}),
|
||||
config: JSON.stringify({
|
||||
serverUrl: apiClient.getApiURL(),
|
||||
token,
|
||||
userId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the react-query cache and mirror every chores update into the
|
||||
* home-screen widgets. Covers both fresh fetches and local mutations
|
||||
* (complete/skip/approve write through the same cache key).
|
||||
*/
|
||||
export const initWidgetSync = queryClient => {
|
||||
if (!isNative()) return () => {}
|
||||
|
||||
let timer = null
|
||||
const schedule = () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
pushSnapshot(queryClient).catch(err =>
|
||||
console.error('Widget sync failed', err),
|
||||
)
|
||||
}, PUSH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
const unsubscribe = queryClient.getQueryCache().subscribe(event => {
|
||||
const key = event?.query?.queryKey
|
||||
if (
|
||||
event?.type === 'updated' &&
|
||||
(key?.[0] === 'chores' ||
|
||||
key?.[0] === 'userProfile' ||
|
||||
key?.[0] === 'allCircleMembers')
|
||||
) {
|
||||
schedule()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/** Wipe widget storage so no task data lingers after logout. */
|
||||
export const clearWidgetData = async () => {
|
||||
if (!isNative()) return
|
||||
try {
|
||||
await WidgetBridge.clear()
|
||||
} catch (err) {
|
||||
console.error('Failed to clear widget data', err)
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,13 @@ class ApiClient {
|
||||
} catch (e) {
|
||||
console.error('Error clearing image cache on logout', e)
|
||||
}
|
||||
try {
|
||||
// Dynamic import sidesteps the ApiClient <-> WidgetService module cycle
|
||||
const { clearWidgetData } = await import('../service/WidgetService')
|
||||
await clearWidgetData()
|
||||
} catch (e) {
|
||||
console.error('Error clearing widget data on logout', e)
|
||||
}
|
||||
try {
|
||||
await logout()
|
||||
} catch (e) {
|
||||
|
||||
@@ -77,7 +77,7 @@ const MyChores = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const Navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||
const { data: projects = [], isLoading: projectsLoading } = useProjects()
|
||||
const {
|
||||
@@ -439,6 +439,18 @@ const MyChores = () => {
|
||||
setSelectedProjectWithCache,
|
||||
])
|
||||
|
||||
// Widget "+" deep link (donetick://chores/add → /chores?add_task=1):
|
||||
// open the quick-add modal once and strip the param so back/refresh
|
||||
// doesn't re-trigger it.
|
||||
useEffect(() => {
|
||||
if (searchParams.get('add_task') === '1') {
|
||||
setAddTaskModalOpen(true)
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('add_task')
|
||||
setSearchParams(params, { replace: true })
|
||||
}
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
// Read and apply filters from URL parameters
|
||||
useEffect(() => {
|
||||
// Check for filterId (camelCase) or filter_id (snake_case) for advanced filters
|
||||
|
||||
Reference in New Issue
Block a user