Commit graph

2 commits

Author SHA1 Message Date
Johannes Millan
c18055a341
fix(android): stop the widget labelling a stale list as Today (#9098) (#9118)
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.
2026-07-17 16:06:06 +02:00
Johannes Millan
bf710637d2
feat(android): add home screen widget for today's tasks (#8737)
* 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>
2026-07-03 18:32:34 +02:00