The widget renders a pre-computed snapshot Angular pushes into `widget_data`;
the blob carried no expiry, so a process that stayed dead across midnight kept
rendering yesterday's tasks under a hardcoded "Today" header, indefinitely.
Native cannot recompute the list. TODAY_TAG membership is virtual, today's
repeat instances do not exist as entities until TaskDueEffects materializes
them on a day change, and overdue carry-over runs there too — so there is no
persisted field a native filter could read that would give the right answer
even in principle. Only running the app can produce today's list.
So make the widget honest instead of wrong: Angular stamps the snapshot with
`validUntil`, the instant it stops being today (start-of-next-day offset
included), and `dayStr` for the label. Native's whole verdict is
`WidgetData.headerFor` → `now >= validUntil`; when stale it renders the
snapshot's own date instead of "Today". The list stays visible and useful, it
just stops claiming to be today's. A stale snapshot whose day cannot be read
says "Outdated" rather than falling back to the very lie this fixes.
Shipping the boundary rather than its inputs keeps the app's calendar rules in
one language. The iOS port (#8950) can consume `validUntil` unchanged instead
of mirroring getDbDateStr semantics into Swift, where a non-Gregorian default
locale would silently misread the day. The verdict is a pure function so it is
unit-testable: this project has no Robolectric, and a decision left inside the
provider could ship inverted and green.
Both refresh paths rebuild the header. A push can change the day the blob
describes; a tap cannot, but it re-renders at a later `now` than the last
verdict was computed at — and it is the one interaction that reaches our code
while the app is dead, so a tap on a new day must not redraw rows under a
"Today" header. Also fixed: onUpdate re-registered the adapter with an
unchanged intent, which does not re-invoke the factory's onDataSetChanged(),
so the periodic update rendered whatever rows the adapter last built.
`v` stays 1: the fields are additive, and parse() returns empty for any other
version, so a bump would blank the widget of every install until next opened.
A pre-#9098 blob has no `validUntil` and is never reported stale — unknown must
not read as expired.
Known bounds, documented in the plan rather than papered over: the label flips
only on a push, a tap, or the inexact Doze-deferred 30-min periodic update, and
the launcher paints cached views on unlock — so the lie is bounded, not
eliminated. Force-stop is not a gap: the system masks a stopped package's
widget entirely, so the reported symptom can only occur in the Doze band. An
exact alarm at `validUntil` would close the rest; deferred on cost/scope, not
because it would not work.
Verified: 25/25 Kotlin across 6 timezones incl. midnight-gap zones (Santiago,
Apia); 203/203 Angular in both timezones CI runs. Boundary math asserted
against the real getDbDateStr rollover. Sabotage-measured, not assumed:
inverting the verdict fails 5 tests, >=→> fails 4, dropping the validUntil
guard fails 3, Locale.US→getDefault fails 1, dropping the day round-trip
fails 1.
* feat(android): add home screen widget for today's tasks (#3818)
Revives PR #7124 (POC by @ilvez) on current master with the review
punch-list addressed:
- today's tasks pushed as a widget_data KeyValStore snapshot (memoized
selector, debounced, hydration-guarded, re-pushed on sync-window end
and on pause); Angular is the single writer of the blob
- done-checkbox taps go through a SharedPreferences queue only; pending
taps are overlaid natively at render time (no native blob write, no
race with the serializer, no double setDone)
- row title tap opens the app via fill-in extras branching (single
PendingIntent template); exported receiver no longer lists the custom
actions in its intent-filter
- typed v:1 contract (android-widget.model.ts <-> WidgetData.kt) locked
by golden-shape tests on both ends
- drain dedupes and skips missing/already-done tasks; aggregated
translated snack
- KeyValStore: drop per-call db.close() (SQLiteOpenHelper caches the
connection; access stays @Synchronized via the App singleton)
Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>
* feat(android): polish widget UI and support toggling done state
Visual polish to match the app:
- rounded surface card in the app's exact light/dark tokens
(#f8f8f7 / #131314) with automatic day/night switching
- branded header (SP logo + 'Today') with separator, matching
brand purple (#8b4a9d light / #a05db1 dark)
- app-style circle-check on the row end (Material check_circle in
brand color when done), dimmed title for done tasks
- project dot tinted with the project color and hidden for
project-less tasks; whole row and empty state open the app
Done-state toggle (was done-only):
- queue is now a last-wins map {taskId: targetIsDone}; tapping a
done task queues setUnDone, and a done->undone round trip before
the app runs collapses to a no-op
- checkbox target computed from the DISPLAYED state (incl. pending
overlay) so repeated taps toggle back and forth while app is dead
- drain applies setDone/setUnDone, still skipping missing tasks and
tasks already in the target state
---------
Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>