Add support for iOS and android widgets

This commit is contained in:
Mo Tarbin
2026-07-18 17:10:23 -04:00
parent 74d658f928
commit b73586f81b
42 changed files with 2898 additions and 14 deletions

View File

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

View File

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

View 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)
}
}

View File

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

View File

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