30 Commits

Author SHA1 Message Date
47cf99af32 fix(data): correct the tasks-provider interaction end to end
A review of every path through the OpenTasks/tasks.org ContentProvider,
prompted by edited due times reverting. Four independent defects produced that
one symptom, plus several unrelated ones alongside.

Edits reverting
- The edit form was bound from a LaunchedEffect in the nav host while its
  ViewModel survives on the back stack, and bindEdit replaced state wholesale.
  MainActivity declares no configChanges, so any Activity recreation (rotation,
  theme/font/display-size change, split-screen, unfolding) re-fired the effect
  and overwrote in-progress edits with the stored row. Guarded with a `bound`
  flag; picker state moved to rememberSaveable so an open picker also survives.

All-day handling
- All-day items are date-only in iCalendar and belong at UTC midnight with a
  null tz. The app wrote *local* midnight, so in Berlin an all-day task drifted
  back a day on every save cycle, corrupting anything synced. Rendering had the
  mirror bug, so the two cancelled out locally and hid each other.
- Toggling the all-day switch flipped the flag but left the timestamp, so an
  all-day task toggled off read back as 02:00 — another apparent "time reset".
- New domain/AllDayTime.kt owns the two conventions and the conversion between
  them; the picker, the write mapper and the toggle all go through it.

Provider write contract
- DUE and DURATION are mutually exclusive and the provider validates the merged
  row, so saving a due date onto a task that carried a duration threw
  IllegalArgumentException — the save simply failed. DURATION is now cleared
  alongside every time write.
- A recurring task's start/due are read from the instances view, and writing
  them back to tasks/<id> re-anchored the whole series. Updates now go through
  instances/<id>, where the provider forks an override instead.
- Recurrence is derived from rrule/rdate rather than the is_recurring column:
  that column only exists from OpenTasks 1.4.0 (DB 23) and is absent on
  tasks.org's bundled provider (DB 22), where it would report every recurring
  task as one-off and send its edits to the anchor.

Reminders
- The per-task Reminder field in the edit form was inert: never persisted,
  never read back, and REMINDER_WITHOUT_DUE could block a save over a value
  that was discarded regardless. Leads are now stored as Alarm property rows
  and preferred over the per-list/global setting. Written before the task
  update so a recurrence fork copies them onto the override. Note the provider
  fires nothing itself — ReminderScheduler still arms the alarm.
- Reminders were keyed by task id over rows read from the instances view, so
  .toMap() collapsed a recurring task to one arbitrary occurrence (the query is
  unsorted). Now keyed per occurrence, with request codes and intent data to
  match. Missed reminders within 6h fire once on boot instead of being dropped.

Robustness
- Four terminal `catch`es killed their upstream on the first provider failure.
  SettingsViewModel is collected in setContent above the permission gate for the
  Activity's lifetime, so a pre-grant SecurityException left the list picker
  empty until the process restarted. Replaced with capped-backoff retry.
- lazyChildren had no catch at all; an exception escaped stateIn past
  viewModelScope's SupervisorJob and crashed the process.
- Observer registration is all-or-nothing (the second register throwing leaked
  the first), ProviderChangeReceiver validates action and authority and
  debounces, and the permission gate re-checks on resume.

Also drops the unused DateTimeField composable and the stale INSTANCES
projection, which omitted the recurrence columns the mapper now depends on.

Bumps floret-kit to pick up the matching all-day formatting fix.

Verified by unit tests (43 app, 15 core-time) and a clean assembleDebug; the
provider interaction itself has not been exercised on a device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:13:26 +02:00
26628dc0bb chore: drop stray Codeberg release scratch files
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
cb-payload.json and cb-response.json are generated at runtime by the
release workflow; they were committed by accident from a local run and
still held stale v0.3.1 data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:30:13 +02:00
d2e3832ef2 Merge branch 'chore/floret-kit-multi-reminders' 2026-07-20 21:04:05 +02:00
93857135b3 Merge branch 'home-today-ring-upcoming' 2026-07-20 21:04:02 +02:00
36beb2d0ad ci(release): wait for the mirrored tag, never mint it
The Codeberg publish has never once succeeded — 0.2.1, 0.2.2, 0.3.0, 0.3.1
and 0.3.2 all failed, and 0.3.0 was published by hand. Neither previous fix
could have worked, because the cause isn't in this file: ref writes to
jlmakiola/agendula on Codeberg fail. A tag push returns "cannot lock
references" and POST /tags returns an empty-bodied 500, while the identical
calls succeed on jlmakiola/calendula with the same token. Creating a release
mints a tag, so it inherited the same failure.

Attaching a release to a tag that is ALREADY there needs no ref write and
returns 201. So split the responsibility the way it should have been: the
push mirror owns delivering the tag, this step only attaches to it. Poll for
the tag, then POST with no target_commitish so the API attaches rather than
resolves a commit and mints one. If the tag never lands, fail with a pointer
at the mirror instead of trying to create it.

Drop continue-on-error. It reported green across five releases that never
published, which is how 0.3.1's crash fix reached F-Droid while the Codeberg
and Obtainium users who hit the crash got nothing.

Tested against the live API before committing (unlike its predecessors):
tag-present attaches 201, tag-absent exits 1 with the diagnostic. Note this
does not by itself restore publishing — the mirror cannot write the v0.3.2
tag either, so Codeberg must repair the repo's ref store first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:50:57 +02:00
bc70ed3a9f release: cut 0.3.2 — get the crash fix onto Codeberg
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 11m59s
2026-07-20 19:11:55 +02:00
3f166ef5f0 release: cut 0.3.2 — get the crash fix onto Codeberg
No app-code change from 0.3.1: this exists to re-run the release pipeline
now that the Codeberg publish step is fixed. 0.3.1's APK reached F-Droid but
its Codeberg release 500'd, so anyone installing from Codeberg or Obtainium
is still on the crashing 0.3.0 — cutting 0.3.2 gets them the fix and proves
the workflow fix in the only way that counts, a real release.

Chosen over hand-patching the 0.3.1 release onto Codeberg: same outcome for
users, minus a manual APK upload, and it actually exercises the pipeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:11:48 +02:00
41bd49826a ci(release): create the Codeberg release while the tag is absent
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
2026-07-20 19:10:56 +02:00
05c75bafa7 ci(release): create the Codeberg release while the tag is absent
Revert 4aa65ed's approach. It read the Forgejo 500 as being caused by
target_commitish, and pushed the tag to Codeberg first so the release could
attach to an existing tag without one. That inverted the actual failure:
Forgejo 500s on POST /releases precisely WHEN the tag already exists, so
pre-pushing it guarantees the error it was meant to avoid.

The second half then can't recover — a bare tag is not a release, so
GET /releases/tags 404s and the upsert has no id, which is the
"Could not resolve Codeberg release id" that ended the 0.3.1 run.

0.3.0 published fine because it POSTed while the tag was still unsynced and
let the API mint tag + release together. Do that deliberately instead of by
luck: if no release owns the tag, delete the mirrored tag, then POST with
target_commitish. Branches mirror reliably (main was already at the release
commit when 0.3.1 failed), so the commit is present and only the raced-in
tag was in the way. Deleting it is safe exactly because no release owns it.

An existing release still takes the PATCH path and skips the delete, so
re-runs never disturb something already published.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:06:36 +02:00
cfa25b9730 release: cut 0.3.1 — fix the launch crash in minified builds (#1)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 11m31s
2026-07-20 18:36:47 +02:00
9d7fc64b0b release: cut 0.3.1 — fix the launch crash in minified builds
R8 kept androidx.work.impl.WorkDatabase_Impl but pruned its no-arg
constructor: nothing calls it directly, Room only reaches it reflectively.
Room then threw InstantiationException, surfacing as "Failed to create an
instance of androidx.work.impl.WorkDatabase". WorkManager builds that
database from a startup ContentProvider, so 0.3.0 died before any of our
code ran — every install, every launch.

We don't depend on WorkManager directly; it arrives via Glance. AGP 9's
stricter R8 is what tipped this over, which is why 0.3.0 was the first
release to hit it.

Keep the Room no-arg constructor, and the ListenableWorker constructor
alongside it — same pruning hazard on the path WorkManager uses to
instantiate workers by name, which would have bitten once a Glance widget
update actually ran.

Reproduced the reporter's stack trace frame-for-frame on a releaseTest
build, then confirmed it launches clean afterwards. Fixes #1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:35:41 +02:00
4aa65edb45 ci(release): push tag to Codeberg before creating the release
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
The first fix didn't help: the pipeline creates the tag via the Gitea
API, and the push mirror (sync_on_commit only fires on real git pushes)
doesn't propagate an API-created tag promptly. So the Codeberg release
POST still raced the mirror and 500'd on a commit/tag Codeberg hadn't
received (0.2.1 and 0.3.0 both shipped everywhere but Codeberg).

Push the tag straight to Codeberg from the runner (guaranteed present),
then attach the release to that existing tag with no target_commitish —
which is what Forgejo 500s on. Race-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:48:29 +02:00
a9843e25b7 release: cut 0.3.0
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m27s
Ships the M5 reminders onboarding + Settings screen alongside the reworked
overview (Today progress ring, live Upcoming preview, unfurling search) and
the real launcher mark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:25:14 +02:00
5f4711dabe ui: move the action press flourish onto the shape, spin the gear
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been cancelled
The press animation turned the glyph; put it on the scalloped cookie
container instead and make it stronger (scale 1→0.82, rotate 0→40°). The
glyph holds upright via a counter-rotation, so the shape spins while a
magnifier or list icon stays readable. New spinIcon opts a glyph into its own
quarter turn — the settings gear uses it, so it reads as a gear cranking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
6d3fbc05c1 home: unfurl search from a fixed search icon, drop the title
Rework the top bar into a custom row with no app title (the launcher icon
already names the app). Settings stays pinned at the right and the search
action sits just left of it; neither moves. Tapping search unfurls a pill
leftward from the magnifier (expandHorizontally anchored at the end) holding
the auto-focused query field and a clear button, with the search icon
remaining as the bar's fixed trailing icon. Results render over the home
content as you type; a blank query leaves the home screen visible. Tapping
the icon again or system back closes search; the FAB hides while searching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
721b411579 icon: centre the launcher mark on the canvas
The first pass shifted the mark off-centre. Scale 0.66 about the canvas
centre, and centre the task CARD (not the card+bloom bounding box, which the
overhanging bloom badge drags low): pivot the Y-scale at the card's centre
(y=242.76) and translate +13.24 so the card sits dead-centre, horizontally
and vertically, with the bloom badging out to the lower-right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
046b8f7e9e home: move search behind a top-bar action, not an always-on bar
The inline search bar was permanently visible. Replace it with a search
action button (a 6-sided cookie shape, sibling to the settings cookie) to
the left of settings: the search bar is absent until tapped, then opens
expanded and auto-focused, covering the home content with live results.
Back arrow or system back closes it; the FAB hides only while searching.
Pass windowInsets = 0 so the bar, already below the app bar, does not
re-apply the status-bar inset and float with a large top gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
06cc9b1c8b icon: real launcher mark — task card, check, calendula bloom
Replace the placeholder check-mark foreground with the finished agendula
mark (converted from design/icon/agendula_icon.svg): a rounded line-art task
card with a check, plus a small Calendula bloom badge in the open
bottom-right corner — the sibling of Calendula's calendar mark.

Strokes render in Calendula's off-white (#FAF6F0) over agendula's existing
plum background (#7A5C6B, the hue-rotated counterpart of Calendula's slate),
so the two apps read as a family while staying distinct. Scaled 0.66 to
match Calendula's footprint and ~2.8dp stroke weight; reused as the
<monochrome> slot for themed icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
2d366a7be3 home: inline expanding search over all tasks
Add a Material 3 SearchBar overlaying the top of the overview. Collapsed it
is a "Search tasks" bar below the title row; tapping expands it in place to
cover the home content with live results, filtering every task (open and
completed) by title, case-insensitive. The leading icon flips to a back
arrow while expanded, a clear button empties the query, and the FAB hides so
it does not float over the results. Results reuse the upcoming preview row.

ListsViewModel.Content now carries allTasks (open + completed) as the search
corpus; filtering stays in memory so the provider query is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
52aeebcb53 home: Today progress ring + live Upcoming preview
Rework the overview's 2x2 smart grid into a daily-momentum layout:

- Promote Today into a full-width hero with an M3 Expressive
  CircularWavyProgressIndicator over "x of y done" for tasks due today.
  Empty/all-done states read as a calm finished state, not a bare 0.
- Drop the white "Upcoming 0" tile (it shouted loudest while carrying the
  least) in favour of a live preview of the next few upcoming tasks, each a
  slim row with the quiet meta line and a tap-through to the task.
- Overdue + All fall back to a 2-up of the existing tonal tiles.

ListsViewModel now combines a Smart(COMPLETED) flow so the ring can count
the tasks already ticked off today (the open smart lists drop them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
8f6b85008c release: cut 0.2.2
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been cancelled
Re-cut after 0.2.1's Codeberg publish failed (500 on pre-synced tag,
fixed in the previous commit). Validates the fix end to end and ships
the Codeberg direct-download channel (signed APK + SHA-256).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:14:57 +02:00
eb1e530ce7 ci(release): fix Codeberg publish 500 on pre-synced tag
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
The push mirror (sync_on_commit) syncs the tag to Codeberg before the
publish step runs, and Forgejo returns HTTP 500 on POST /releases with a
target_commitish when the tag already exists — so the release was never
created (v0.2.1 shipped to Gitea/F-Droid but not Codeberg). Only pass
target_commitish when the tag isn't mirrored yet; otherwise attach the
release to the existing tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:07:37 +02:00
976d496d21 reminders: adopt floret-kit multi-value override model
Bumps the floret-kit submodule to the multi-value core-reminders API
(ReminderOverride.Minutes now a List<Int>) and adapts the per-list override
plumbing + pickers to it. Agendula stays single-reminder: overrides are
one-element (or empty) lists, reminderLeadFor takes firstOrNull, and the
single-select picker wraps/unwraps listOf. On-disk format is unchanged (single
values round-trip byte-identically). Verified: :app:compileDebugKotlin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:47:50 +02:00
245f1db536 ui: move the action press flourish onto the shape, spin the gear
The press animation turned the glyph; put it on the scalloped cookie
container instead and make it stronger (scale 1→0.82, rotate 0→40°). The
glyph holds upright via a counter-rotation, so the shape spins while a
magnifier or list icon stays readable. New spinIcon opts a glyph into its own
quarter turn — the settings gear uses it, so it reads as a gear cranking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:01:07 +02:00
623e533547 home: unfurl search from a fixed search icon, drop the title
Rework the top bar into a custom row with no app title (the launcher icon
already names the app). Settings stays pinned at the right and the search
action sits just left of it; neither moves. Tapping search unfurls a pill
leftward from the magnifier (expandHorizontally anchored at the end) holding
the auto-focused query field and a clear button, with the search icon
remaining as the bar's fixed trailing icon. Results render over the home
content as you type; a blank query leaves the home screen visible. Tapping
the icon again or system back closes search; the FAB hides while searching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:00:42 +02:00
2e50356f81 icon: centre the launcher mark on the canvas
The first pass shifted the mark off-centre. Scale 0.66 about the canvas
centre, and centre the task CARD (not the card+bloom bounding box, which the
overhanging bloom badge drags low): pivot the Y-scale at the card's centre
(y=242.76) and translate +13.24 so the card sits dead-centre, horizontally
and vertically, with the bloom badging out to the lower-right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:35:03 +02:00
e6f503c02a home: move search behind a top-bar action, not an always-on bar
The inline search bar was permanently visible. Replace it with a search
action button (a 6-sided cookie shape, sibling to the settings cookie) to
the left of settings: the search bar is absent until tapped, then opens
expanded and auto-focused, covering the home content with live results.
Back arrow or system back closes it; the FAB hides only while searching.
Pass windowInsets = 0 so the bar, already below the app bar, does not
re-apply the status-bar inset and float with a large top gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:34:54 +02:00
c53511196d icon: real launcher mark — task card, check, calendula bloom
Replace the placeholder check-mark foreground with the finished agendula
mark (converted from design/icon/agendula_icon.svg): a rounded line-art task
card with a check, plus a small Calendula bloom badge in the open
bottom-right corner — the sibling of Calendula's calendar mark.

Strokes render in Calendula's off-white (#FAF6F0) over agendula's existing
plum background (#7A5C6B, the hue-rotated counterpart of Calendula's slate),
so the two apps read as a family while staying distinct. Scaled 0.66 to
match Calendula's footprint and ~2.8dp stroke weight; reused as the
<monochrome> slot for themed icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:07:25 +02:00
a8595e26b4 home: inline expanding search over all tasks
Add a Material 3 SearchBar overlaying the top of the overview. Collapsed it
is a "Search tasks" bar below the title row; tapping expands it in place to
cover the home content with live results, filtering every task (open and
completed) by title, case-insensitive. The leading icon flips to a back
arrow while expanded, a clear button empties the query, and the FAB hides so
it does not float over the results. Results reuse the upcoming preview row.

ListsViewModel.Content now carries allTasks (open + completed) as the search
corpus; filtering stays in memory so the provider query is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:07:12 +02:00
411e27659f home: Today progress ring + live Upcoming preview
Rework the overview's 2x2 smart grid into a daily-momentum layout:

- Promote Today into a full-width hero with an M3 Expressive
  CircularWavyProgressIndicator over "x of y done" for tasks due today.
  Empty/all-done states read as a calm finished state, not a bare 0.
- Drop the white "Upcoming 0" tile (it shouted loudest while carrying the
  least) in favour of a live preview of the next few upcoming tasks, each a
  slim row with the quiet meta line and a tap-through to the task.
- Overdue + All fall back to a 2-up of the existing tonal tiles.

ListsViewModel now combines a Smart(COMPLETED) flow so the ring can count
the tasks already ticked off today (the open smart lists drop them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:47:02 +02:00
47 changed files with 1464 additions and 380 deletions

View File

@@ -370,11 +370,13 @@ jobs:
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset. # release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
- name: Publish release to Codeberg - name: Publish release to Codeberg
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
continue-on-error: true # NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
# 0.3.0, 0.3.1 and 0.3.2 while never once publishing, which is how a
# crash-fix release reached F-Droid but not the Codeberg/Obtainium
# users who needed it. A broken mirror must fail the release loudly.
env: env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }} TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
SHA: ${{ github.sha }}
run: | run: |
set -e set -e
if [ -z "${TOKEN:-}" ]; then if [ -z "${TOKEN:-}" ]; then
@@ -399,20 +401,48 @@ jobs:
sed -i -e '/./,$!d' release-notes.md sed -i -e '/./,$!d' release-notes.md
fi fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md [ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > cb-payload.json # Never mint the tag here. Gitea's push mirror owns getting it to
# Codeberg; this step's only job is to attach a release to a tag that
# has already landed. That split matters because every way of creating
# a tag from here — git push, or a release POST carrying
# target_commitish for a tag Codeberg lacks — is a ref WRITE, and ref
# writes are what fail on this repo ("cannot lock references" on push,
# an empty-bodied 500 on the API). Attaching to a tag that is already
# present needs no ref write and succeeds.
#
# So: wait for the mirror, verify, then attach. If the tag never shows
# up, fail — do NOT fall back to creating it, which is what produced
# the silent breakage across 0.2.1 through 0.3.2.
TAG_OK=""
for i in $(seq 1 30); do
if [ "$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $TOKEN" "$API/tags/$TAG")" = "200" ]; then
TAG_OK=1; echo "Codeberg has $TAG (after ~$((i*10))s)"; break
fi
sleep 10
done
if [ -z "$TAG_OK" ]; then
echo "Codeberg never received $TAG from the push mirror (waited 300s)." >&2
echo "Not creating it here: ref writes to this repo fail, so that" >&2
echo "would 500. Check the mirror, then re-run once the tag is there." >&2
exit 1
fi
# No target_commitish: the tag exists, so the API must attach to it
# rather than resolve a commit and mint one.
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
import json, sys import json, sys
tag, pre = sys.argv[1:3]
print(json.dumps({ print(json.dumps({
"tag_name": sys.argv[1], "tag_name": tag,
"target_commitish": sys.argv[2], "name": tag,
"name": sys.argv[1],
"body": open("release-notes.md").read(), "body": open("release-notes.md").read(),
"draft": False, "draft": False,
# Pre-1.0 releases are flagged as pre-releases (see detect job). # Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": sys.argv[3] == "true", "prerelease": pre == "true",
})) }))
PY PY
# Upsert (re-run safe). POST also creates the tag at target_commitish # Upsert (re-run safe): a release already attached to this tag is
# if the push mirror hasn't synced it yet. # PATCHed in place, so re-running never disturbs a published release.
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty') ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -n "$ID" ]; then if [ -n "$ID" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \ curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \

View File

@@ -7,23 +7,43 @@ All notable changes to this project are documented here. The format follows
## [Unreleased] ## [Unreleased]
## [0.3.2] - 2026-07-20
### Fixed
- Releases reach the Codeberg download channel again. 0.3.1 published to
F-Droid but never appeared on Codeberg, so if you install from there — or
through Obtainium — this is the release that finally carries 0.3.0's
launch-crash fix. The app itself is unchanged from 0.3.1.
## [0.3.1] - 2026-07-20
### Fixed
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
release build stripped a constructor that the background-work scheduler needs
to open its database, and that happens before the app draws anything.
## [0.3.0] - 2026-07-19
### Added ### Added
- M5 reminders onboarding & polish: a one-time reminder onboarding step after the - Reminders: Agendula now delivers your due reminders itself. A one-time setup
provider grant explains that Agendula delivers due reminders itself and requests step explains this and asks for notification access, and a master switch in
`POST_NOTIFICATIONS` (API 33+). A new **Settings** screen (gear on the lists Settings turns the whole thing off again.
overview), structured after Calendula as a category hub with sliding sub-screens - A Settings screen, from the gear on the overview: appearance and theme, which
(`CollapsingScaffold` + grouped category rows + full-screen `OptionPicker`): fields the task form shows, your default list, and reminder defaults.
an About card, **Appearance** (theme, dynamic colour), **Task form** (which - The overview leads with Today — a progress ring showing how much of today
optional edit-form fields show by default, default list, the add-a-subtask-row you've finished — followed by a live preview of what's coming up next.
opt-out), and **Reminders** (a master enable switch that re-requests the - Search across every task, open or completed, from the top bar.
notification permission, a "when to remind" default offset, and — on Android 12 - A proper app icon.
only — an exact-alarm status row that deep-links to system settings).
- The reminders master switch gates the whole engine: turning it off clears every ### Changed
scheduled alarm and suppresses any that fire. - A tidier top bar: no app title, with search and settings pinned to the right.
- Expressive top-bar actions: a reusable `ShapedActionButton` wraps an action icon
in a tonal M3 Expressive `MaterialShapes` container (the settings entry uses the ## [0.2.2] - 2026-07-19
faceted Gem shape, tertiary tones), with a small press scale/rotate flourish.
Future top-bar actions pick their own shape from the `ActionShapes` registry. ### Fixed
- Release automation now reliably mirrors each release to the Codeberg mirror
(signed APK + SHA-256 checksum). The 0.2.1 attempt failed when the release tag
had already been synced to Codeberg.
## [0.2.1] - 2026-07-19 ## [0.2.1] - 2026-07-19

View File

@@ -29,8 +29,8 @@ android {
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked // PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
// as a pre-release while MAJOR is 0. See docs/RELEASING.md. // as a pre-release while MAJOR is 0. See docs/RELEASING.md.
versionCode = 201 versionCode = 302
versionName = "0.2.1" versionName = "0.3.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -2,5 +2,20 @@
-keep class dagger.hilt.** { *; } -keep class dagger.hilt.** { *; }
-keep @dagger.hilt.android.HiltAndroidApp class * -keep @dagger.hilt.android.HiltAndroidApp class *
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
# nothing calls it directly Room then throws InstantiationException, reported
# as "Failed to create an instance of ...". We pull Room in transitively via
# Glance -> WorkManager, whose WorkDatabase is built by WorkManagerInitializer
# at startup, so the app died on launch in every minified build (issue #1).
-keep class * extends androidx.room.RoomDatabase { <init>(); }
# WorkManager likewise looks its workers up by name and calls this constructor
# reflectively same pruning, but it only bites once a worker actually runs
# (Glance's widget updates), so keep it explicitly rather than wait for it.
-keep class * extends androidx.work.ListenableWorker {
<init>(android.content.Context, androidx.work.WorkerParameters);
}
# Compose Compiler may keep its own; defaults are fine # Compose Compiler may keep its own; defaults are fine
-dontwarn org.jetbrains.annotations.** -dontwarn org.jetbrains.annotations.**

View File

@@ -12,7 +12,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
import de.jeanlucmakiola.floret.reminders.applyReminderOverride import de.jeanlucmakiola.floret.reminders.applyReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderLeadFor import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@@ -39,15 +39,17 @@ data class Settings(
val bottomAddBar: Boolean = false, val bottomAddBar: Boolean = false,
/** /**
* Per-list overrides of [reminderLeadMinutes]: a list present in the map * Per-list overrides of [reminderLeadMinutes]: a list present in the map
* overrides the global default (a null value = no reminder); absent = inherit. * overrides the global default (an empty list = no reminder); absent =
* inherit. Agendula offers a single reminder, so each override is a
* one-element (or empty) list.
*/ */
val perListReminderOverride: Map<Long, Int?> = emptyMap(), val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */ /** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set<TaskFormField> = emptySet(), val defaultEditFields: Set<TaskFormField> = emptySet(),
) { ) {
/** The lead time for a task in [listId]: its override if set, else the global default. */ /** The lead time for a task in [listId]: its override if set, else the global default. */
fun reminderLeadFor(listId: Long): Int? = fun reminderLeadFor(listId: Long): Int? =
perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes) perListReminderOverride.reminderLeadsFor(listId, listOf(reminderLeadMinutes)).firstOrNull()
} }
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */ /** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
@@ -45,7 +46,14 @@ class DueReminderReceiver : BroadcastReceiver() {
companion object { companion object {
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID" private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
fun intent(context: Context, taskId: Long): Intent = /**
Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId) * [triggerAt] rides in the intent *data*, not just an extra: PendingIntent
* identity ignores extras, so two occurrences of the same recurring task
* would otherwise collapse into one alarm under FLAG_UPDATE_CURRENT.
*/
fun intent(context: Context, taskId: Long, triggerAt: Long): Intent =
Intent(context, DueReminderReceiver::class.java)
.setData("agendula://reminder/$taskId/$triggerAt".toUri())
.putExtra(EXTRA_TASK_ID, taskId)
} }
} }

View File

@@ -3,7 +3,9 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.SystemClock
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -20,10 +22,25 @@ import javax.inject.Inject
class ProviderChangeReceiver : BroadcastReceiver() { class ProviderChangeReceiver : BroadcastReceiver() {
@Inject lateinit var scheduler: ReminderScheduler @Inject lateinit var scheduler: ReminderScheduler
@Inject lateinit var providerResolver: ProviderResolver
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
// The receiver has to stay exported to hear the provider's broadcast, and
// the sender holds no permission we could require — so validate the
// broadcast itself. Without this, any installed app can spam a full
// re-sync (an unbounded provider read) by firing a matching intent.
if (intent.action != Intent.ACTION_PROVIDER_CHANGED) return
val authority = providerResolver.resolve()?.authority ?: return
if (intent.data?.host != authority) return
// External sync can fire these in bursts; one re-sync per burst is plenty.
val now = SystemClock.elapsedRealtime()
synchronized(Companion) {
if (now - lastSyncAt < MIN_SYNC_INTERVAL_MS) return
lastSyncAt = now
}
val pending = goAsync() val pending = goAsync()
scope.launch { scope.launch {
try { try {
@@ -33,4 +50,11 @@ class ProviderChangeReceiver : BroadcastReceiver() {
} }
} }
} }
private companion object {
const val MIN_SYNC_INTERVAL_MS = 10_000L
@Volatile
var lastSyncAt = -MIN_SYNC_INTERVAL_MS
}
} }

View File

@@ -43,35 +43,57 @@ class ReminderScheduler @Inject constructor(
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
.getOrElse { return@withContext } .getOrElse { return@withContext }
// One reminder per *occurrence*: the instances view yields a row per
// occurrence, all sharing a taskId, so this is a Set rather than a
// taskId-keyed Map — keying by task would collapse a daily recurring task
// down to one arbitrary reminder (the query is unsorted, so which one
// survived was provider-defined).
// Per-task leads, stored as Alarm property rows. One query for all of them.
val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() }
val desired = tasks val desired = tasks
.filter { !it.isClosed && it.due != null } .filter { !it.isClosed && it.due != null }
.mapNotNull { task -> .mapNotNull { task ->
// The task's list may override the global lead, or opt out entirely // A reminder set on the task itself wins; otherwise the task's list
// (override = null), in which case it gets no reminder at all. // may override the global lead, or opt out entirely (override =
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null // null), in which case it gets no reminder at all.
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L) val lead = perTask[task.taskId]
?: settings.reminderLeadFor(task.listId)
?: return@mapNotNull null
ScheduledReminder(
taskId = task.taskId,
triggerAt = task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L,
)
} }
.toMap() // The lower bound trails `now` so a reminder missed while the device was
.filterValues { it in now..horizon } // off still fires once on boot instead of being silently dropped —
// setExactAndAllowWhileIdle delivers a past trigger immediately. Anything
// already armed stays armed (the diff below), so it can't re-fire.
.filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon }
.toSet()
val previous = store.all() val previous = store.all()
(previous.keys - desired.keys).forEach { cancel(it) } (previous - desired).forEach { cancel(it) }
desired.forEach { (taskId, triggerAt) -> (desired - previous).forEach { schedule(it) }
if (previous[taskId] != triggerAt) schedule(taskId, triggerAt)
}
store.replace(desired) store.replace(desired)
} }
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java) private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? { private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? {
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags) return PendingIntent.getBroadcast(
context,
reminder.requestCode,
DueReminderReceiver.intent(context, reminder.taskId, reminder.triggerAt),
flags,
)
} }
private fun schedule(taskId: Long, triggerAt: Long) { private fun schedule(reminder: ScheduledReminder) {
val pi = pendingIntent(taskId, create = true) ?: return val triggerAt = reminder.triggerAt
val pi = pendingIntent(reminder, create = true) ?: return
val am = alarmManager() val am = alarmManager()
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms() val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
if (canExact) { if (canExact) {
@@ -81,19 +103,21 @@ class ReminderScheduler @Inject constructor(
} }
} }
private fun cancel(taskId: Long) { private fun cancel(reminder: ScheduledReminder) {
pendingIntent(taskId, create = false)?.let { pendingIntent(reminder, create = false)?.let {
alarmManager().cancel(it) alarmManager().cancel(it)
it.cancel() it.cancel()
} }
} }
private suspend fun clearAll() { private suspend fun clearAll() {
store.all().keys.forEach { cancel(it) } store.all().forEach { cancel(it) }
store.replace(emptyMap()) store.replace(emptySet())
} }
private companion object { private companion object {
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
/** How long after its trigger a missed reminder is still worth firing. */
const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours
} }
} }

View File

@@ -9,25 +9,38 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Remembers which task reminders are currently scheduled (taskId → trigger time), * One armed alarm. A recurring task has many occurrences sharing a [taskId], so
* so [ReminderScheduler] can diff against a fresh computation and cancel only the * the trigger time is part of the identity — keying by task alone would collapse
* alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings. * a daily task down to a single reminder.
*/
data class ScheduledReminder(val taskId: Long, val triggerAt: Long) {
/**
* Request code for this alarm's PendingIntent. Derived from both fields so
* sibling occurrences don't share (and overwrite) one alarm slot.
*/
val requestCode: Int get() = (taskId * 31 + triggerAt).hashCode()
}
/**
* Remembers which task reminders are currently armed, so [ReminderScheduler] can
* diff against a fresh computation and touch only the alarms that changed.
* Persisted in DataStore as a set of `taskId|trigger` strings.
*/ */
@Singleton @Singleton
class ScheduledReminderStore @Inject constructor( class ScheduledReminderStore @Inject constructor(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
) { ) {
suspend fun all(): Map<Long, Long> = suspend fun all(): Set<ScheduledReminder> =
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry -> dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
val parts = entry.split('|') val parts = entry.split('|')
val id = parts.getOrNull(0)?.toLongOrNull() val id = parts.getOrNull(0)?.toLongOrNull()
val at = parts.getOrNull(1)?.toLongOrNull() val at = parts.getOrNull(1)?.toLongOrNull()
if (id != null && at != null) id to at else null if (id != null && at != null) ScheduledReminder(id, at) else null
}.toMap() }.toSet()
suspend fun replace(scheduled: Map<Long, Long>) { suspend fun replace(scheduled: Set<ScheduledReminder>) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet() prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet()
} }
} }

View File

@@ -11,6 +11,7 @@ import android.os.Looper
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
@@ -98,6 +99,50 @@ class AndroidTasksDataSource @Inject constructor(
if (rows == 0) throw TaskWriteFailedException("update task $taskId") if (rows == 0) throw TaskWriteFailedException("update task $taskId")
} }
override fun updateInstance(instanceId: Long, form: TaskForm) {
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
val uri = TasksContract.propertiesUri(authority())
// Replace rather than update: the provider's AlarmHandler re-validates the
// whole row on every update, so a partial edit throws — and delete+insert
// means we never have to track property_id.
resolver.delete(
uri,
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE),
)
if (minutesBeforeDue != null) {
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
?: throw TaskWriteFailedException("set alarm for task $taskId")
}
}
override fun alarms(): Map<Long, Int> {
val uri = TasksContract.propertiesUri(authority())
val projection = arrayOf(Properties.TASK_ID, TasksContract.Alarm.MINUTES_BEFORE)
return resolver.query(
uri,
projection,
"${Properties.MIMETYPE} = ?",
arrayOf(TasksContract.Alarm.MIMETYPE),
null,
)?.use { c ->
val reader = CursorColumnReader(c)
buildMap {
while (c.moveToNext()) {
val id = reader.getLong(Properties.TASK_ID)
val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE)
if (id != null && minutes != null) put(id, minutes)
}
}
} ?: emptyMap()
}
override fun setCompleted(taskId: Long, completed: Boolean) { override fun setCompleted(taskId: Long, completed: Boolean) {
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis()) val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
@@ -127,9 +172,16 @@ class AndroidTasksDataSource @Inject constructor(
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) = onChange() override fun onChange(selfChange: Boolean) = onChange()
} }
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer) // Register both or neither: if the second call throws, the first
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer) // registration would otherwise leak (no AutoCloseable was handed back yet).
return AutoCloseable { resolver.unregisterContentObserver(observer) } try {
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
} catch (e: RuntimeException) {
runCatching { resolver.unregisterContentObserver(observer) }
throw e
}
return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } }
} }
private fun Map<String, Any?>.toContentValues(): ContentValues { private fun Map<String, Any?>.toContentValues(): ContentValues {

View File

@@ -0,0 +1,31 @@
package de.jeanlucmakiola.agendula.data.tasks
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.retryWhen
private const val BASE_RETRY_MS = 1_000L
private const val MAX_RETRY_MS = 30_000L
/** 1s, 2s, 4s … capped at 30s, so a permanently-absent provider costs little. */
private fun retryDelayMs(attempt: Long): Long =
(BASE_RETRY_MS shl attempt.coerceAtMost(5).toInt()).coerceAtMost(MAX_RETRY_MS)
/**
* Recover a provider-backed flow without killing it.
*
* Provider reads fail for reasons that resolve on their own: the read permission
* isn't granted yet (first launch collects before the permission gate), or the
* provider app is mid-update. A terminal `catch` swallows the failure *and*
* cancels the upstream, so the flow never produces again — the screen stays empty
* until the process restarts, even after the user grants the permission.
*
* This emits [fallback] instead and keeps retrying with a capped backoff, so the
* collector recovers on its own once the provider becomes readable.
*/
fun <T> Flow<T>.recoveringFromProviderFailure(fallback: () -> T): Flow<T> =
retryWhen { _, attempt ->
emit(fallback())
delay(retryDelayMs(attempt))
true
}

View File

@@ -38,7 +38,14 @@ object TaskMapper {
listName = r.getString(Tasks.LIST_NAME), listName = r.getString(Tasks.LIST_NAME),
accountName = r.getString(Tasks.ACCOUNT_NAME), accountName = r.getString(Tasks.ACCOUNT_NAME),
parentId = r.getLong(Tasks.PARENT_ID), parentId = r.getLong(Tasks.PARENT_ID),
isRecurring = r.getBoolean(Instances.IS_RECURRING), // Derived from the rule columns rather than the `is_recurring` column
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
// absent on tasks.org's bundled provider (DB 22), where reading it
// would silently report every recurring task as one-off — and route
// its edits onto the series anchor.
isRecurring = r.getString(Tasks.RRULE) != null ||
r.getString(Tasks.RDATE) != null ||
r.getBoolean(Instances.IS_RECURRING),
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT), distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
created = instant(Tasks.CREATED), created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED), lastModified = instant(Tasks.LAST_MODIFIED),

View File

@@ -1,8 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
/** Column lists requested from the provider. Order is irrelevant; we read by name. */ /** Column lists requested from the provider. Order is irrelevant; we read by name. */
object TaskProjections { object TaskProjections {
@@ -18,31 +16,9 @@ object TaskProjections {
Lists.ACCOUNT_TYPE, Lists.ACCOUNT_TYPE,
) )
/** Read from the `instances` view (inherits all task columns). */ // No `instances` projection on purpose: that read passes `projection = null`
val INSTANCES: Array<String> = arrayOf( // (all columns), because the view's shape differs across provider versions —
Tasks.ID, // tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list
Instances.TASK_ID, // here would drift out of sync with the by-name mapper and quietly drop
Tasks.LIST_ID, // columns it depends on. See AndroidTasksDataSource.queryInstances.
Tasks.TITLE,
Tasks.DESCRIPTION,
Tasks.LOCATION,
Tasks.URL,
Tasks.PRIORITY,
Tasks.STATUS,
Tasks.PERCENT_COMPLETE,
Tasks.COMPLETED,
Tasks.IS_ALLDAY,
Tasks.TZ,
Instances.INSTANCE_START,
Instances.INSTANCE_DUE,
Tasks.TASK_COLOR,
Tasks.LIST_COLOR,
Tasks.LIST_NAME,
Tasks.ACCOUNT_NAME,
Tasks.PARENT_ID,
Instances.IS_RECURRING,
Instances.DISTANCE_FROM_CURRENT,
Tasks.CREATED,
Tasks.LAST_MODIFIED,
)
} }

View File

@@ -1,9 +1,21 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.toICal import de.jeanlucmakiola.agendula.domain.toICal
import kotlin.time.Instant
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
private fun Instant.forAllDay(allDay: Boolean): Instant =
if (!allDay) this
else Instant.fromEpochMilliseconds(
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
)
/** /**
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues), * Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
@@ -39,8 +51,17 @@ object TaskWriteMapper {
} }
} }
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0) put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
put(Tasks.DTSTART, form.start?.toEpochMilliseconds()) // All-day tasks are date-only in iCalendar. The provider reads them back
put(Tasks.DUE, form.due?.toEpochMilliseconds()) // through DateTime.toAllDay(), which drops the time-of-day and resolves the
// remaining date against UTC — so a local-midnight instant lands on the
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
// DUE and DURATION are mutually exclusive. The provider's Validating
// processor evaluates the *merged* row (supplied values over the stored
// ones), so writing DUE onto a task that already carries a DURATION throws
// "Only one of DUE or DURATION must be supplied." Clear it alongside.
put(Tasks.DURATION, null)
put(Tasks.PARENT_ID, form.parentId) put(Tasks.PARENT_ID, form.parentId)
// The provider treats a null tz as local time; set it explicitly for // The provider treats a null tz as local time; set it explicitly for
// timed tasks so the stored instant is unambiguous across zones. // timed tasks so the stored instant is unambiguous across zones.
@@ -48,6 +69,16 @@ object TaskWriteMapper {
put(Tasks.TZ, if (timed) tzId else null) put(Tasks.TZ, if (timed) tzId else null)
} }
/**
* Values for an update through the *instances* URI (a recurring occurrence).
* The provider clones the row into an override and strips list/recurrence
* fields as it goes, so LIST_ID and PARENT_ID are dropped here rather than
* written and silently ignored — moving one occurrence between lists or
* parents isn't a thing the override model expresses.
*/
fun instanceValues(form: TaskForm, tzId: String): Map<String, Any?> =
taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> = fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
if (completed) { if (completed) {
mapOf( mapOf(
@@ -63,6 +94,19 @@ object TaskWriteMapper {
) )
} }
/**
* A reminder for [taskId], as an Alarm property row. The provider's validator
* requires MINUTES_BEFORE, REFERENCE (non-negative) and ALARM_TYPE on every
* write, so all three are always present.
*/
fun alarmValues(taskId: Long, minutesBeforeDue: Int): Map<String, Any?> = mapOf(
Properties.TASK_ID to taskId,
Properties.MIMETYPE to Alarm.MIMETYPE,
Alarm.MINUTES_BEFORE to minutesBeforeDue,
Alarm.REFERENCE to Alarm.REFERENCE_DUE,
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
)
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf( fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf(
Lists.NAME to name.trim(), Lists.NAME to name.trim(),
Lists.COLOR to color, Lists.COLOR to color,

View File

@@ -66,6 +66,9 @@ object TasksContract {
const val IS_ALLDAY = "is_allday" const val IS_ALLDAY = "is_allday"
const val TZ = "tz" const val TZ = "tz"
const val RRULE = "rrule" const val RRULE = "rrule"
const val RDATE = "rdate"
/** Set on an override row — the master occurrence this one replaces. */
const val ORIGINAL_INSTANCE_ID = "original_instance_id"
const val PARENT_ID = "parent_id" const val PARENT_ID = "parent_id"
const val SORTING = "sorting" const val SORTING = "sorting"
const val CREATED = "created" const val CREATED = "created"
@@ -98,6 +101,50 @@ object TasksContract {
const val IS_RECURRING = "is_recurring" const val IS_RECURRING = "is_recurring"
} }
/** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */
object Properties {
const val PATH = "properties"
const val PROPERTY_ID = "property_id"
const val TASK_ID = "task_id"
const val MIMETYPE = "mimetype"
}
/**
* An alarm property row — a per-task reminder lead.
*
* Storage and sync format *only*: the provider fires nothing (its alarm
* scheduling is commented out and the internal `alarms` table is never
* populated), so [de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler]
* still arms the real AlarmManager alarm. Writing it here is what makes the
* lead survive a sync and show up in other OpenTasks clients.
*
* The columns are the generic `dataN` slots; the meanings below are the
* Alarm property's contract for them.
*/
object Alarm {
const val MIMETYPE = "vnd.android.cursor.item/alarm"
/** `data0` — minutes from the reference date; positive means *before* it. */
const val MINUTES_BEFORE = "data0"
/** `data1` — which date to count from. */
const val REFERENCE = "data1"
/** `data2` — optional message shown with the alarm. */
const val MESSAGE = "data2"
/** `data3` — alarm kind. Must be present, and non-zero to count as an alarm. */
const val ALARM_TYPE = "data3"
const val REFERENCE_DUE = 1
const val REFERENCE_START = 2
/** 0 (NOTHING) is excluded from the provider's `has_alarms` count — use MESSAGE. */
const val TYPE_MESSAGE = 1
}
fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}")
// --- status values (TaskColumns.STATUS_*) -------------------------------- // --- status values (TaskColumns.STATUS_*) --------------------------------
const val STATUS_NEEDS_ACTION = 0 const val STATUS_NEEDS_ACTION = 0
const val STATUS_IN_PROCESS = 1 const val STATUS_IN_PROCESS = 1
@@ -112,6 +159,15 @@ object TasksContract {
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}") fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}") fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
/**
* A single occurrence. Updating through this URI is how a *recurring* task is
* edited: the provider clones the row into an override task
* (`original_instance_id` set, recurrence fields stripped) instead of moving
* the series anchor, which is what writing to `tasks/<id>` would do.
*/
fun instanceUri(authority: String, instanceId: Long): Uri =
Uri.parse("content://$authority/${Instances.PATH}/$instanceId")
/** Append the sync-adapter params required to write local-account rows. */ /** Append the sync-adapter params required to write local-account rows. */
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri = fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
uri.buildUpon() uri.buildUpon()

View File

@@ -23,6 +23,25 @@ interface TasksDataSource {
fun insertTask(form: TaskForm): Long fun insertTask(form: TaskForm): Long
fun updateTask(taskId: Long, form: TaskForm) fun updateTask(taskId: Long, form: TaskForm)
/**
* Update a single occurrence of a recurring task, addressed by its *instance*
* row id. The provider forks an override task rather than moving the series
* anchor — which is what [updateTask] would do, since a recurring task's
* start/due are read from the instances view.
*/
fun updateInstance(instanceId: Long, form: TaskForm)
/**
* Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm
* property row. The provider never fires it — [de.jeanlucmakiola.agendula
* .data.reminders.ReminderScheduler] does — but persisting it here is what
* syncs the lead and shares it with other OpenTasks clients.
*/
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
/** Every task's reminder lead, by task id. One query, for the scheduler. */
fun alarms(): Map<Long, Int>
fun setCompleted(taskId: Long, completed: Boolean) fun setCompleted(taskId: Long, completed: Boolean)
fun deleteTask(taskId: Long) fun deleteTask(taskId: Long)
fun createLocalList(name: String, color: Int): Long fun createLocalList(name: String, color: Int): Long

View File

@@ -39,6 +39,13 @@ interface TasksRepository {
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null) suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
suspend fun setCompleted(taskId: Long, completed: Boolean) suspend fun setCompleted(taskId: Long, completed: Boolean)
suspend fun deleteTask(taskId: Long) suspend fun deleteTask(taskId: Long)
/**
* The per-task reminder lead in minutes before due, or `null` if the task has
* none (in which case the list's / global setting applies). Read when the edit
* form loads so saving can't silently drop it.
*/
suspend fun reminderFor(taskId: Long): Int?
suspend fun createLocalList(name: String, color: Int): Long suspend fun createLocalList(name: String, color: Int): Long
/** Synchronous snapshot for the permission/onboarding gate. */ /** Synchronous snapshot for the permission/onboarding gate. */

View File

@@ -80,18 +80,39 @@ class TasksRepositoryImpl @Inject constructor(
} }
override suspend fun createTask(form: TaskForm): Long = override suspend fun createTask(form: TaskForm): Long =
withContext(io) { dataSource.insertTask(form) } withContext(io) {
val id = dataSource.insertTask(form)
form.reminderMinutesBeforeDue?.let { dataSource.setAlarm(id, it) }
id
}
override suspend fun reminderFor(taskId: Long): Int? =
withContext(io) { runCatching { dataSource.alarms()[taskId] }.getOrNull() }
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) = override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
withContext(io) { withContext(io) {
// Conflict-safe overwrite: re-read just before writing and bail if the // Re-read just before writing: it settles the conflict check *and* tells
// provider's last_modified moved since the form captured it (external // us which URI to write through.
// sync / another app). A null baseline means "force / overwrite anyway". val current = dataSource.task(taskId)
// Conflict-safe overwrite: bail if the provider's last_modified moved
// since the form captured it (external sync / another app). A null
// baseline means "force / overwrite anyway".
if (expectedLastModified != null) { if (expectedLastModified != null) {
val current = dataSource.task(taskId)?.lastModified val seen = current?.lastModified
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId) if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
}
// Write the reminder first: forking a recurring occurrence copies the
// task's properties onto the new override row, so setting the alarm
// beforehand is what carries it across.
dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue)
// A recurring task's start/due come from the instances view, so writing
// them back to tasks/<id> would re-anchor the whole series. Going through
// the occurrence lets the provider fork an override instead.
if (current != null && current.isRecurring) {
dataSource.updateInstance(current.id, form)
} else {
dataSource.updateTask(taskId, form)
} }
dataSource.updateTask(taskId, form)
} }
override suspend fun setCompleted(taskId: Long, completed: Boolean) = override suspend fun setCompleted(taskId: Long, completed: Boolean) =

View File

@@ -0,0 +1,42 @@
package de.jeanlucmakiola.agendula.domain
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
/**
* All-day tasks are date-only in iCalendar. OpenTasks reads them back through
* `DateTime.toAllDay()`, which discards the time-of-day and resolves the
* remaining date against UTC — so the storage convention is **UTC midnight of
* the intended calendar date, with a null timezone**. Timed tasks, by contrast,
* are ordinary instants rendered in the device's zone.
*
* These two conventions disagree about which day a given instant is, which is
* why every all-day value needs an explicit conversion rather than a raw
* `Instant` passed straight through.
*/
/** UTC midnight of [date] — the storage form for an all-day value. */
fun allDayInstantOf(date: java.time.LocalDate): Instant =
Instant.fromEpochMilliseconds(date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli())
/**
* The calendar date this instant denotes: read in UTC for [allDay] values,
* in [zone] for timed ones.
*/
fun Instant.calendarDate(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
.atZone(if (allDay) ZoneOffset.UTC else zone)
.toLocalDate()
/**
* Move an instant across the two conventions when the all-day switch flips, so
* the day the user is looking at stays put. Without this, toggling all-day off
* turns a UTC-midnight value into "02:00" in Berlin (or the previous day, 19:00,
* in New York) — reading to the user as "the time reset itself".
*/
fun Instant.rebasedForAllDay(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): Instant =
if (allDay) allDayInstantOf(calendarDate(allDay = false, zone = zone))
else Instant.fromEpochMilliseconds(
calendarDate(allDay = true).atStartOfDay(zone).toInstant().toEpochMilli(),
)

View File

@@ -48,6 +48,12 @@ data class Task(
val listName: String?, val listName: String?,
val accountName: String?, val accountName: String?,
val parentId: Long?, val parentId: Long?,
/**
* This row carries a recurrence rule, so [id] is one occurrence of a series
* and [start]/[due] are that occurrence's resolved times — *not* the master's
* anchor. Edits must go through the instances URI (see
* [de.jeanlucmakiola.agendula.data.tasks.TasksContract.instanceUri]).
*/
val isRecurring: Boolean, val isRecurring: Boolean,
val distanceFromCurrent: Int?, val distanceFromCurrent: Int?,
val created: Instant?, val created: Instant?,

View File

@@ -11,12 +11,16 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
@@ -39,6 +43,18 @@ fun RootScreen(
ActivityResultContracts.RequestMultiplePermissions(), ActivityResultContracts.RequestMultiplePermissions(),
) { permissionViewModel.refresh() } ) { permissionViewModel.refresh() }
// Re-check on every resume, not just after the in-app request: the user may
// have granted the permission (or installed a provider) in system Settings and
// come back, and otherwise the gate would hold until the process restarts.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) permissionViewModel.refresh()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
when (permission.status) { when (permission.status) {
ProviderStatus.NO_PROVIDER -> Gate( ProviderStatus.NO_PROVIDER -> Gate(
modifier = modifier, modifier = modifier,

View File

@@ -1,167 +0,0 @@
package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.time.formatDateTime
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Event
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import de.jeanlucmakiola.agendula.R
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
private val zone: ZoneId get() = ZoneId.systemDefault()
internal fun Instant.toLocalDate(): LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
internal fun Instant.toLocalTime(): LocalTime =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
/**
* A labelled date(-time) field for the edit form: a tonal row showing the
* current value (or nothing), tappable to pick a date and — unless [allDay] —
* a time. A clear affordance appears once a value is set. Emits `null` when
* cleared. Styled to match the app's rounded tonal family.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateTimeField(
label: String,
value: Instant?,
allDay: Boolean,
onChange: (Instant?) -> Unit,
modifier: Modifier = Modifier,
) {
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
var pendingDate by remember { mutableStateOf<LocalDate?>(null) }
Surface(
onClick = { showDatePicker = true },
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set),
style = MaterialTheme.typography.bodyLarge,
)
}
if (value != null) {
IconButton(onClick = { onChange(null) }) {
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
}
}
}
}
if (showDatePicker) {
val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()))
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
showDatePicker = false
val millis = dateState.selectedDateMillis ?: return@TextButton
val date = java.time.Instant.ofEpochMilli(millis)
.atZone(ZoneOffset.UTC).toLocalDate()
if (allDay) {
onChange(localToInstant(date, LocalTime.MIDNIGHT))
} else {
pendingDate = date
showTimePicker = true
}
},
) { Text(stringResource(android.R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
) { DatePicker(state = dateState) }
}
if (showTimePicker) {
val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())
val timeState = rememberTimePickerState(
initialHour = base.toLocalTime().hour,
initialMinute = base.toLocalTime().minute,
)
Dialog(onDismissRequest = { showTimePicker = false }) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TimePicker(state = timeState)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { showTimePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
TextButton(onClick = {
showTimePicker = false
val date = pendingDate ?: return@TextButton
onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute)))
}) { Text(stringResource(android.R.string.ok)) }
}
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
package de.jeanlucmakiola.agendula.ui.common
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import kotlin.time.Instant
/**
* Zone helpers shared by the date/time pickers. All-day conversions live in
* [de.jeanlucmakiola.agendula.domain.AllDayTime] — these cover the timed case,
* where the device zone is the right frame of reference.
*/
private val zone: ZoneId get() = ZoneId.systemDefault()
internal fun Instant.toLocalTime(): LocalTime =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())

View File

@@ -54,7 +54,9 @@ fun ReminderLeadPicker(
onDismiss: () -> Unit, onDismiss: () -> Unit,
presets: List<Int> = REMINDER_PRESETS, presets: List<Int> = REMINDER_PRESETS,
) { ) {
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes // Agendula is single-reminder: an override carries a one-element list, so
// take the single value for this single-select picker.
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes?.firstOrNull()
val customSelected = selectedMinutes != null && selectedMinutes !in presets val customSelected = selectedMinutes != null && selectedMinutes !in presets
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected }) val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
@@ -65,7 +67,7 @@ fun ReminderLeadPicker(
val options = buildList { val options = buildList {
if (allowInherit) add(ReminderOverride.Inherit) if (allowInherit) add(ReminderOverride.Inherit)
if (allowNone) add(ReminderOverride.None) if (allowNone) add(ReminderOverride.None)
presets.forEach { add(ReminderOverride.Minutes(it)) } presets.forEach { add(ReminderOverride.Minutes(listOf(it))) }
} }
val rowCount = options.size + 1 // + the custom row val rowCount = options.size + 1 // + the custom row
@@ -105,7 +107,7 @@ fun ReminderLeadPicker(
unit = unit, unit = unit,
onUnitChange = { unit = it }, onUnitChange = { unit = it },
onConfirm = { minutes -> onConfirm = { minutes ->
onSelect(ReminderOverride.Minutes(minutes)) onSelect(ReminderOverride.Minutes(listOf(minutes)))
onDismiss() onDismiss()
}, },
) )
@@ -168,5 +170,5 @@ private fun CustomReminderEditor(
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) { private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
ReminderOverride.None -> stringResource(R.string.reminder_none) ReminderOverride.None -> stringResource(R.string.reminder_none)
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes) is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes.first())
} }

View File

@@ -31,8 +31,10 @@ import androidx.graphics.shapes.RoundedPolygon
* its own [shape] and [containerColor], so a row of them reads as a set of * its own [shape] and [containerColor], so a row of them reads as a set of
* distinct little tokens rather than identical grey glyphs. * distinct little tokens rather than identical grey glyphs.
* *
* Pressing springs the icon down a touch and gives it a small turn — a light * Pressing springs the whole shape down and gives it a turn — the scalloped
* expressive flourish, no library motion APIs needed. * container is what dips and spins, while the glyph inside stays upright (a
* spinning magnifier or list icon would just read as wrong). [spinIcon] opts a
* glyph into turning too, for icons that read well mid-spin like the gear.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -46,15 +48,25 @@ fun ShapedActionButton(
contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer, contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer,
size: Dp = 40.dp, size: Dp = 40.dp,
iconSize: Dp = 22.dp, iconSize: Dp = 22.dp,
spinIcon: Boolean = false,
) { ) {
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState() val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.88f else 1f, label = "actionScale") // The shape (the scalloped cookie) turns and dips on press.
val rotation by animateFloatAsState(if (pressed) 24f else 0f, label = "actionRotation") val shapeRotation by animateFloatAsState(if (pressed) 40f else 0f, label = "shapeRotation")
val scale by animateFloatAsState(if (pressed) 0.82f else 1f, label = "shapeScale")
// The glyph's *net* turn: 0 keeps it upright, spinIcon gives it a quarter turn.
val iconRotation by animateFloatAsState(
if (pressed && spinIcon) 90f else 0f,
label = "iconRotation",
)
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier.size(size), modifier = modifier
.size(size)
.scale(scale)
.rotate(shapeRotation),
shape = shape.toShape(), shape = shape.toShape(),
color = containerColor, color = containerColor,
contentColor = contentColor, contentColor = contentColor,
@@ -64,10 +76,11 @@ fun ShapedActionButton(
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = contentDescription, contentDescription = contentDescription,
// Counter the container's turn so the glyph's net rotation is just
// [iconRotation] — upright by default, a quarter turn for the gear.
modifier = Modifier modifier = Modifier
.size(iconSize) .size(iconSize)
.scale(scale) .rotate(iconRotation - shapeRotation),
.rotate(rotation),
) )
} }
} }
@@ -82,4 +95,7 @@ fun ShapedActionButton(
object ActionShapes { object ActionShapes {
/** Settings — a 4-sided cookie (rounded, scalloped square). */ /** Settings — a 4-sided cookie (rounded, scalloped square). */
val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided
/** Search — a 6-sided cookie, the same family as [Settings] but distinct. */
val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided
} }

View File

@@ -501,13 +501,16 @@ private fun taskWhenLines(task: Task): Pair<String, String?>? {
val due = task.due val due = task.due
return when { return when {
start != null && due != null -> { start != null && due != null -> {
val sameDay = start.formatDate() == due.formatDate() val allDay = task.isAllDay
val primary = if (sameDay) due.formatDate() else "${start.formatDate()} ${due.formatDate()}" val sameDay = start.formatDate(allDay) == due.formatDate(allDay)
val secondary = if (task.isAllDay) null else "${start.formatTime()} ${due.formatTime()}" val primary =
if (sameDay) due.formatDate(allDay)
else "${start.formatDate(allDay)} ${due.formatDate(allDay)}"
val secondary = if (allDay) null else "${start.formatTime()} ${due.formatTime()}"
primary to secondary primary to secondary
} }
due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime() due != null -> due.formatDate(task.isAllDay) to if (task.isAllDay) null else due.formatTime()
start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime() start != null -> start.formatDate(task.isAllDay) to if (task.isAllDay) null else start.formatTime()
else -> null else -> null
} }
} }

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
@@ -11,7 +12,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -42,7 +42,7 @@ class TaskDetailViewModel @Inject constructor(
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail) if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
} }
.onStart { emit(TaskDetailUiState.Loading) } .onStart { emit(TaskDetailUiState.Loading) }
.catch { emit(TaskDetailUiState.NotFound) } .recoveringFromProviderFailure { TaskDetailUiState.NotFound }
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)

View File

@@ -99,7 +99,8 @@ import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.agendula.ui.common.localToInstant import de.jeanlucmakiola.agendula.ui.common.localToInstant
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.agendula.ui.common.toLocalDate import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.calendarDate
import de.jeanlucmakiola.agendula.ui.common.toLocalTime import de.jeanlucmakiola.agendula.ui.common.toLocalTime
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import java.time.LocalTime import java.time.LocalTime
@@ -178,7 +179,7 @@ private fun EditContent(
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
val gap = 12.dp val gap = 12.dp
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) } var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) }
var showListPicker by rememberSaveable { mutableStateOf(false) } var showListPicker by rememberSaveable { mutableStateOf(false) }
var showParentPicker by rememberSaveable { mutableStateOf(false) } var showParentPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) } var showReminderPicker by rememberSaveable { mutableStateOf(false) }
@@ -653,7 +654,7 @@ private fun ScheduleRow(
) )
} else { } else {
Text( Text(
text = value.formatDate(), text = value.formatDate(allDay),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = valueColor, color = valueColor,
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp), modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
@@ -689,12 +690,15 @@ private fun DateTimePickerFlow(
onResult: (Instant) -> Unit, onResult: (Instant) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
var pendingDate by remember { mutableStateOf<java.time.LocalDate?>(null) } var pendingDate by rememberSaveable { mutableStateOf<java.time.LocalDate?>(null) }
var showTime by remember { mutableStateOf(false) } var showTime by rememberSaveable { mutableStateOf(false) }
if (!showTime) { if (!showTime) {
// M3's DatePicker speaks UTC millis. An all-day value is already UTC-based,
// a timed one is read in the device zone — calendarDate picks the right frame
// so the dialog opens on the day the rest of the UI shows.
val initialMillis = (initial ?: nowInstant()) val initialMillis = (initial ?: nowInstant())
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() .calendarDate(allDay).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog( DatePickerDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -703,7 +707,7 @@ private fun DateTimePickerFlow(
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton } val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate() val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
if (allDay) { if (allDay) {
onResult(localToInstant(date, LocalTime.MIDNIGHT)) onResult(allDayInstantOf(date))
} else { } else {
pendingDate = date pendingDate = date
showTime = true showTime = true
@@ -865,7 +869,7 @@ private fun ParentPickerSheet(
GroupedRow( GroupedRow(
title = task.title.ifBlank { stringResource(R.string.task_untitled) }, title = task.title.ifBlank { stringResource(R.string.task_untitled) },
position = positionOf(index, section.tasks.size), position = positionOf(index, section.tasks.size),
summary = task.due?.formatDate(), summary = task.due?.formatDate(task.isAllDay),
selected = task.taskId == selectedId, selected = task.taskId == selectedId,
minHeight = 56.dp, minHeight = 56.dp,
onClick = { choose(task.taskId) }, onClick = { choose(task.taskId) },

View File

@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.populatedFields import de.jeanlucmakiola.agendula.domain.populatedFields
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -70,6 +71,15 @@ class TaskEditViewModel @Inject constructor(
private var editingTaskId: Long? = null private var editingTaskId: Long? = null
/**
* Whether the form has already been populated. The host `LaunchedEffect`
* re-fires whenever the composition restarts — an Activity recreation
* (rotation, theme/font/display-size change, split-screen, unfolding) — while
* this ViewModel survives on the nav back stack. Without this guard the
* rebind would overwrite in-progress edits with the untouched provider row.
*/
private var bound = false
/** `last_modified` captured when the form loaded — the conflict-check baseline. */ /** `last_modified` captured when the form loaded — the conflict-check baseline. */
private var baselineLastModified: Instant? = null private var baselineLastModified: Instant? = null
@@ -78,6 +88,8 @@ class TaskEditViewModel @Inject constructor(
/** Start a fresh task, optionally pre-selecting a list / parent. */ /** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null) { fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
if (bound) return
bound = true
editingTaskId = null editingTaskId = null
baselineLastModified = null baselineLastModified = null
viewModelScope.launch { viewModelScope.launch {
@@ -103,6 +115,8 @@ class TaskEditViewModel @Inject constructor(
/** Load an existing task for editing. */ /** Load an existing task for editing. */
fun bindEdit(taskId: Long) { fun bindEdit(taskId: Long) {
if (bound && editingTaskId == taskId) return
bound = true
editingTaskId = taskId editingTaskId = taskId
viewModelScope.launch { viewModelScope.launch {
defaultFields = settingsPrefs.settings.first().defaultEditFields defaultFields = settingsPrefs.settings.first().defaultEditFields
@@ -126,6 +140,7 @@ class TaskEditViewModel @Inject constructor(
priority = task.priority, priority = task.priority,
parentId = task.parentId, parentId = task.parentId,
percentComplete = task.percentComplete, percentComplete = task.percentComplete,
reminderMinutesBeforeDue = repository.reminderFor(taskId),
lists = lists, lists = lists,
parentCandidates = loadParents(task.listId, selfId = taskId), parentCandidates = loadParents(task.listId, selfId = taskId),
), ),
@@ -178,7 +193,19 @@ class TaskEditViewModel @Inject constructor(
fun onStartChange(value: Instant?) = update { it.copy(start = value) } fun onStartChange(value: Instant?) = update { it.copy(start = value) }
fun onDueChange(value: Instant?) = update { it.copy(due = value) } fun onDueChange(value: Instant?) = update { it.copy(due = value) }
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) } /**
* All-day and timed values use different conventions (UTC midnight vs. a real
* instant in the device zone), so the switch has to move the timestamps too —
* flipping the flag alone makes an all-day task read back as "02:00", which
* looks to the user like the time reset itself.
*/
fun onAllDayChange(value: Boolean) = update {
it.copy(
isAllDay = value,
start = it.start?.rebasedForAllDay(value),
due = it.due?.rebasedForAllDay(value),
)
}
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) } fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) } fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) } fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }

View File

@@ -1,8 +1,16 @@
package de.jeanlucmakiola.agendula.ui.lists package de.jeanlucmakiola.agendula.ui.lists
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -12,96 +20,152 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ListAlt import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.Upcoming import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.common.ActionShapes import de.jeanlucmakiola.agendula.ui.common.ActionShapes
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
import java.time.LocalDate
import java.time.ZoneId
/** /**
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live * Home: a Today progress hero (wavy ring over tasks due today), an Overdue + All
* counts, then the user's lists grouped by account. Tapping a card or row opens * 2-up of tonal tiles, a live preview of the next upcoming tasks, then the user's
* that task list; the FAB starts a new task. * lists grouped by account. Tapping a tile opens that smart list, an upcoming row
* opens that task, and the FAB starts a new task. The search action in the top bar
* slides open into a full-width field that filters every task by title.
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ListsScreen( fun ListsScreen(
onOpenFilter: (TaskFilter) -> Unit, onOpenFilter: (TaskFilter) -> Unit,
onOpenTask: (Long) -> Unit,
onNewTask: () -> Unit, onNewTask: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ListsViewModel = hiltViewModel(), viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() var query by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
val closeSearch = {
query = ""
searchActive = false
}
// System back closes search before leaving the screen.
BackHandler(enabled = searchActive, onBack = closeSearch)
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier,
topBar = { topBar = {
MediumTopAppBar( HomeTopBar(
title = { Text(stringResource(R.string.app_name)) }, searchActive = searchActive,
actions = { query = query,
ShapedActionButton( onQueryChange = { query = it },
shape = ActionShapes.Settings, onToggleSearch = { if (searchActive) closeSearch() else searchActive = true },
icon = Icons.Rounded.Settings, onOpenSettings = onOpenSettings,
contentDescription = stringResource(R.string.settings_title),
onClick = onOpenSettings,
modifier = Modifier.padding(end = 8.dp),
size = 48.dp,
iconSize = 26.dp,
)
},
scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
ExtendedFloatingActionButton( // The FAB would otherwise float over the search results.
onClick = onNewTask, if (!searchActive) {
icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.new_task)) }, onClick = onNewTask,
) icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = { Text(stringResource(R.string.new_task)) },
)
}
}, },
) { inner -> ) { inner ->
when (val s = state) { Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission when (val s = state) {
ListsUiState.Failure -> CenteredMessage(stringResource(R.string.lists_failure), inner) ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter) ListsUiState.Failure ->
CenteredMessage(stringResource(R.string.lists_failure), PaddingValues(0.dp))
is ListsUiState.Content -> {
ListsContent(
state = s,
onOpenFilter = onOpenFilter,
onOpenTask = onOpenTask,
topPadding = 0.dp,
bottomPadding = inner.calculateBottomPadding() + 96.dp,
)
// While searching with a non-blank query, results cover the home
// content; an empty query leaves the home content visible behind
// the open field.
if (searchActive) {
SearchResults(
query = query,
allTasks = s.allTasks,
onOpenTask = { taskId ->
closeSearch()
onOpenTask(taskId)
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
} }
} }
} }
@@ -109,17 +173,46 @@ fun ListsScreen(
@Composable @Composable
private fun ListsContent( private fun ListsContent(
state: ListsUiState.Content, state: ListsUiState.Content,
inner: PaddingValues,
onOpenFilter: (TaskFilter) -> Unit, onOpenFilter: (TaskFilter) -> Unit,
onOpenTask: (Long) -> Unit,
topPadding: androidx.compose.ui.unit.Dp,
bottomPadding: androidx.compose.ui.unit.Dp,
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = inner.calculateTopPadding(), top = topPadding,
bottom = inner.calculateBottomPadding() + 96.dp, bottom = bottomPadding,
), ),
) { ) {
item { SmartGrid(state.smartCounts, onOpenFilter) } item {
TodayHero(
done = state.todayDone,
total = state.todayTotal,
onClick = { onOpenFilter(TaskFilter.Smart(SmartList.TODAY)) },
)
}
// Overdue + All sit below the Today hero as a quieter 2-up; Upcoming is no
// longer a count tile — it becomes the live preview further down.
item {
SmartPairRow(
counts = state.smartCounts.filter {
it.smart == SmartList.OVERDUE || it.smart == SmartList.ALL
},
onOpenFilter = onOpenFilter,
)
}
if (state.upcoming.isNotEmpty()) {
item { SectionHeader(stringResource(R.string.smart_upcoming)) }
item {
UpcomingPreview(
tasks = state.upcoming,
onOpenTask = onOpenTask,
onViewAll = { onOpenFilter(TaskFilter.Smart(SmartList.UPCOMING)) },
)
}
}
if (state.groups.isEmpty()) { if (state.groups.isEmpty()) {
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) } item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
@@ -150,27 +243,372 @@ private fun ListsContent(
} }
} }
/**
* The home top bar. There is no title — the launcher icon already says which app
* this is. Settings is pinned at the right; the search action sits just left of it
* and stays put. Tapping search unfurls a pill to its left (the field grows out
* from the icon's side) while the search icon remains as the bar's fixed trailing
* icon, so neither action moves.
*/
@Composable @Composable
private fun SmartGrid(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) { private fun HomeTopBar(
Column( searchActive: Boolean,
modifier = Modifier.padding(horizontal = 16.dp), query: String,
verticalArrangement = Arrangement.spacedBy(8.dp), onQueryChange: (String) -> Unit,
onToggleSearch: () -> Unit,
onOpenSettings: () -> Unit,
) {
Surface(color = MaterialTheme.colorScheme.surface) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.height(72.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Search occupies the flexible space left of settings. Pulled into its
// own composable so AnimatedVisibility resolves to the plain overload —
// a RowScope receiver in here would make that call ambiguous.
SearchSlot(
searchActive = searchActive,
query = query,
onQueryChange = onQueryChange,
onToggleSearch = onToggleSearch,
modifier = Modifier.weight(1f),
)
ShapedActionButton(
shape = ActionShapes.Settings,
icon = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
onClick = onOpenSettings,
modifier = Modifier.padding(start = 8.dp),
size = 48.dp,
iconSize = 26.dp,
spinIcon = true,
)
}
}
}
/**
* The search action and the field it unfurls. The magnifier is pinned to the end
* (right) of this slot and never moves; tapping it toggles search. When active a
* pill expands leftward from it ([expandHorizontally] anchored at the end) holding
* the query field, so the icon reads as the bar's fixed trailing icon.
*/
@Composable
private fun SearchSlot(
searchActive: Boolean,
query: String,
onQueryChange: (String) -> Unit,
onToggleSearch: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier, contentAlignment = Alignment.CenterEnd) {
AnimatedVisibility(
visible = searchActive,
enter = expandHorizontally(tween(300), expandFrom = Alignment.End) + fadeIn(tween(280)),
exit = shrinkHorizontally(tween(220), shrinkTowards = Alignment.End) + fadeOut(tween(140)),
) {
SearchPill(
query = query,
onQueryChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
)
}
// Always on top, at the end — the bar's fixed search icon and toggle.
ShapedActionButton(
shape = ActionShapes.Search,
icon = Icons.Rounded.Search,
contentDescription = stringResource(R.string.home_search_hint),
onClick = onToggleSearch,
size = 48.dp,
iconSize = 26.dp,
)
}
}
/**
* The expanding search input pill: the query field (auto-focused on open) and a
* clear button once there is text. Its trailing 48dp is left empty for the search
* icon that [SearchSlot] overlays at the end.
*/
@Composable
private fun SearchPill(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.height(52.dp),
) { ) {
counts.chunked(2).forEach { row -> Row(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { modifier = Modifier.padding(start = 18.dp),
row.forEach { smart -> verticalAlignment = Alignment.CenterVertically,
SmartCard( ) {
count = smart, BasicTextField(
modifier = Modifier.weight(1f), value = query,
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) }, onValueChange = onQueryChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(),
modifier = Modifier.weight(1f).focusRequester(focusRequester),
decorationBox = { innerField ->
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty()) {
Text(
stringResource(R.string.home_search_hint),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
innerField()
}
},
)
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
Icons.Rounded.Close,
contentDescription = stringResource(R.string.home_search_clear),
) )
} }
if (row.size == 1) Spacer(Modifier.weight(1f)) }
// Space reserved for the search icon SearchSlot overlays at the end.
Spacer(Modifier.width(48.dp))
}
}
}
/**
* Search results overlaying the home content: tasks whose title matches the query,
* across every list (completed included), open ones first. A blank query renders
* nothing so the home content shows through behind the open field.
*/
@Composable
private fun SearchResults(
query: String,
allTasks: List<Task>,
onOpenTask: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
if (query.isBlank()) return
val results = remember(query, allTasks) {
val q = query.trim()
allTasks
.filter { it.title.contains(q, ignoreCase = true) }
.sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() }))
}
Surface(modifier = modifier, color = MaterialTheme.colorScheme.surface) {
if (results.isEmpty()) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.TopCenter) {
Text(
text = stringResource(R.string.home_search_empty, query.trim()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(results, key = { it.id }) { task ->
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
}
} }
} }
} }
} }
/**
* The day's momentum: a wavy progress ring over "x of y done" for tasks due today.
* Tapping opens the Today list. When nothing is due today it drops the ring and
* reads as a calm, finished state rather than an empty 0.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun TodayHero(done: Int, total: Int, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val corner by animateDpAsState(if (pressed) 34.dp else 22.dp, label = "todayCorner")
val left = total - done
Surface(
onClick = onClick,
shape = RoundedCornerShape(corner),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
interactionSource = interaction,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(140.dp),
) {
Row(
modifier = Modifier.fillMaxSize().padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Rounded.Today, contentDescription = null, modifier = Modifier.size(20.dp))
Text(stringResource(R.string.smart_today), style = MaterialTheme.typography.titleMedium)
}
val headline = when {
total == 0 -> stringResource(R.string.home_today_empty)
left == 0 -> stringResource(R.string.home_today_all_done)
else -> stringResource(R.string.home_today_progress, done, total)
}
Text(headline, style = MaterialTheme.typography.headlineSmall)
if (total > 0 && left > 0) {
Text(
stringResource(R.string.home_today_remaining, left),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f),
)
}
}
if (total > 0) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(84.dp)) {
CircularWavyProgressIndicator(
progress = { done.toFloat() / total },
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.onPrimaryContainer,
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.22f),
)
Text("$done/$total", style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
/** Overdue + All as a 2-up row of the existing tonal tiles. */
@Composable
private fun SmartPairRow(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
counts.forEach { smart ->
SmartCard(
count = smart,
modifier = Modifier.weight(1f),
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) },
)
}
if (counts.size == 1) Spacer(Modifier.weight(1f))
}
}
/** A grouped card previewing the next few upcoming tasks, with a "view all" tail. */
@Composable
private fun UpcomingPreview(
tasks: List<Task>,
onOpenTask: (Long) -> Unit,
onViewAll: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Column {
tasks.forEach { task ->
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
}
Surface(onClick = onViewAll, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.home_upcoming_view_all),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.weight(1f))
Icon(
Icons.Rounded.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
/**
* One slim upcoming row: list-colour avatar, title, then a quiet meta line of the
* relative due date and (if set) a tinted priority flag — the same calm one-line
* treatment as the task list, minus the swipe machinery.
*/
@Composable
private fun UpcomingRow(task: Task, onClick: () -> Unit) {
val dark = isSystemInDarkTheme()
val muted = MaterialTheme.colorScheme.onSurfaceVariant
val metaStyle = MaterialTheme.typography.bodySmall
Surface(onClick = onClick, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ListColorChip(task.effectiveColor)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
task.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
upcomingDueLabel(task)?.let { Text(it, style = metaStyle, color = muted) }
if (task.priority != Priority.NONE) {
Text("·", style = metaStyle, color = muted)
Icon(
Icons.Rounded.Flag,
contentDescription = null,
tint = priorityAccent(task.priority, dark),
modifier = Modifier.size(13.dp),
)
Text(priorityLabel(task.priority), style = metaStyle, color = muted)
}
}
}
}
}
}
/** "Today" / "Tomorrow" for the near dates, else the compact date. */
@Composable
private fun upcomingDueLabel(task: Task): String? {
val due = task.due ?: return null
val zone = remember { ZoneId.systemDefault() }
val today = remember { LocalDate.now(zone) }
val dueDate = remember(due) {
java.time.Instant.ofEpochMilli(due.toEpochMilliseconds()).atZone(zone).toLocalDate()
}
return when (dueDate) {
today -> stringResource(R.string.home_due_today)
today.plusDays(1) -> stringResource(R.string.home_due_tomorrow)
else -> due.formatDateTimeCompact(task.isAllDay)
}
}
private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color) private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color)
@Composable @Composable

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.floret.time.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
@@ -12,7 +13,6 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import java.time.ZoneId import java.time.ZoneId
@@ -29,9 +29,18 @@ sealed interface ListsUiState {
data class Content( data class Content(
val smartCounts: List<SmartCount>, val smartCounts: List<SmartCount>,
val groups: List<AccountGroup>, val groups: List<AccountGroup>,
/** Completed vs. total tasks *due today* — drives the progress ring. */
val todayDone: Int,
val todayTotal: Int,
/** The next few open tasks (due tomorrow onward) for the inline preview. */
val upcoming: List<Task>,
/** Every task (open and completed) — the corpus the home search filters. */
val allTasks: List<Task>,
) : ListsUiState ) : ListsUiState
} }
private const val UPCOMING_PREVIEW = 3
/** The home overview: smart lists with live counts, then user lists by account. */ /** The home overview: smart lists with live counts, then user lists by account. */
@HiltViewModel @HiltViewModel
class ListsViewModel @Inject constructor( class ListsViewModel @Inject constructor(
@@ -42,12 +51,19 @@ class ListsViewModel @Inject constructor(
combine( combine(
repository.taskLists(), repository.taskLists(),
repository.tasks(TaskFilter.Smart(SmartList.ALL)), repository.tasks(TaskFilter.Smart(SmartList.ALL)),
) { lists, openTasks -> // Open smart lists drop completed tasks, but the Today ring needs the
buildContent(lists, openTasks) as ListsUiState // ones already ticked off to show "x of y done", so read them too.
}.catch { emit(ListsUiState.Failure) } repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
) { lists, openTasks, completedTasks ->
buildContent(lists, openTasks, completedTasks) as ListsUiState
}.recoveringFromProviderFailure { ListsUiState.Failure }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
private fun buildContent(lists: List<TaskList>, openTasks: List<Task>): ListsUiState.Content { private fun buildContent(
lists: List<TaskList>,
openTasks: List<Task>,
completedTasks: List<Task>,
): ListsUiState.Content {
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
// Count only top-level tasks: a subtask is represented by its parent (and // Count only top-level tasks: a subtask is represented by its parent (and
// its progress chip), and an open subtask under a *completed* parent must // its progress chip), and an open subtask under a *completed* parent must
@@ -62,6 +78,21 @@ class ListsViewModel @Inject constructor(
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)), SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
SmartCount(SmartList.ALL, topLevel.size), SmartCount(SmartList.ALL, topLevel.size),
) )
// Today ring: completed vs. total tasks *due today*. The numerator is the
// top-level completed tasks whose due date falls in today's window; the
// denominator adds the still-open ones (the Today smart count above).
val openToday = count(SmartList.TODAY)
val completedDueToday = completedTasks.count {
!it.isSubtask && it.due != null && it.due >= todayStart && it.due < todayEnd
}
val todayTotal = openToday + completedDueToday
// Upcoming preview: the next handful of open tasks due tomorrow onward,
// already sorted by the repository's default ordering.
val upcoming = topLevel
.filter { TaskFiltering.matches(it, TaskFilter.Smart(SmartList.UPCOMING), todayStart, todayEnd) }
.take(UPCOMING_PREVIEW)
val openByList = topLevel.groupingBy { it.listId }.eachCount() val openByList = topLevel.groupingBy { it.listId }.eachCount()
val groups = lists val groups = lists
.groupBy { it.accountName } .groupBy { it.accountName }
@@ -72,6 +103,13 @@ class ListsViewModel @Inject constructor(
) )
} }
.sortedBy { it.accountName.lowercase() } .sortedBy { it.accountName.lowercase() }
return ListsUiState.Content(smartCounts, groups) return ListsUiState.Content(
smartCounts = smartCounts,
groups = groups,
todayDone = completedDueToday,
todayTotal = todayTotal,
upcoming = upcoming,
allTasks = openTasks + completedTasks,
)
} }
} }

View File

@@ -52,6 +52,7 @@ fun AgendulaNavHost(modifier: Modifier = Modifier) {
composable(Dest.LISTS) { composable(Dest.LISTS) {
ListsScreen( ListsScreen(
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) }, onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) },
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) }, onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
onOpenSettings = { nav.navigate(Dest.SETTINGS) }, onOpenSettings = { nav.navigate(Dest.SETTINGS) },
) )

View File

@@ -94,6 +94,7 @@ import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
/** The settings sub-screens reached from the hub's category rows. */ /** The settings sub-screens reached from the hub's category rows. */
@@ -483,10 +484,10 @@ private fun RemindersScreen(
if (showOffset) { if (showOffset) {
ReminderLeadPicker( ReminderLeadPicker(
title = stringResource(R.string.settings_default_reminder), title = stringResource(R.string.settings_default_reminder),
selected = ReminderOverride.Minutes(state.settings.reminderLeadMinutes), selected = ReminderOverride.Minutes(listOf(state.settings.reminderLeadMinutes)),
allowInherit = false, allowInherit = false,
allowNone = false, allowNone = false,
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) }, onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes.first()) },
onDismiss = { showOffset = false }, onDismiss = { showOffset = false },
) )
} }
@@ -504,14 +505,8 @@ private fun RemindersScreen(
} }
/** The stored override for [listId], as a picker choice (absent → inherit). */ /** The stored override for [listId], as a picker choice (absent → inherit). */
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride { private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride =
val map = state.settings.perListReminderOverride state.settings.perListReminderOverride.reminderOverrideFor(listId)
return when {
!map.containsKey(listId) -> ReminderOverride.Inherit
map[listId] == null -> ReminderOverride.None
else -> ReminderOverride.Minutes(map.getValue(listId)!!)
}
}
/** Row summary for a list: its override, or the inherited global default. */ /** Row summary for a list: its override, or the inherited global default. */
@Composable @Composable
@@ -519,7 +514,7 @@ private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): S
ReminderOverride.Inherit -> ReminderOverride.Inherit ->
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault)) stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
ReminderOverride.None -> stringResource(R.string.reminder_none) ReminderOverride.None -> stringResource(R.string.reminder_none)
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes) is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes.first())
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -7,12 +7,12 @@ import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -33,8 +33,14 @@ class SettingsViewModel @Inject constructor(
repository: TasksRepository, repository: TasksRepository,
) : ViewModel() { ) : ViewModel() {
// MainActivity collects this for the theme, above the permission gate and for
// the whole Activity lifetime — so the list flow must survive the pre-grant
// SecurityException and recover once permission is given, not die for good.
val state: StateFlow<SettingsUiState> = val state: StateFlow<SettingsUiState> =
combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists -> combine(
prefs.settings,
repository.taskLists().recoveringFromProviderFailure { emptyList() },
) { settings, lists ->
SettingsUiState(settings, lists) SettingsUiState(settings, lists)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
@@ -12,7 +13,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@@ -87,7 +87,10 @@ class TaskListViewModel @Inject constructor(
} }
} }
.onStart { emit(TaskListUiState.Loading) } .onStart { emit(TaskListUiState.Loading) }
.catch { emit(TaskListUiState.Failure) } // Recover rather than terminate: a provider hiccup (mid-update,
// permission not yet granted) shows Failure but keeps retrying,
// so the screen heals itself instead of staying stuck.
.recoveringFromProviderFailure { TaskListUiState.Failure }
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
@@ -112,6 +115,9 @@ class TaskListViewModel @Inject constructor(
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() } combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
} }
} }
// Without this an exception here escapes stateIn's coroutine, past
// viewModelScope's SupervisorJob, and crashes the process.
.recoveringFromProviderFailure { emptyMap() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
/** The screen reports which expanded parents need their children fetched. */ /** The screen reports which expanded parents need their children fetched. */

View File

@@ -1,22 +1,89 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Agendula launcher icon foreground — PLACEHOLDER. Agendula launcher icon foreground.
A simple rounded check mark inside the 108dp adaptive-icon canvas Converted from design/icon/agendula_icon.svg (512x512 viewport): a rounded
(72dp safe zone). Deliberately not Calendula's calendar mark, so the two line-art task card with a check mark, plus a small Calendula bloom badge in
apps never look alike. Replace with real branding (a stylized agendula) the bottom-right corner — the sibling of Calendula's calendar mark.
when the design lands — see docs/PLAN.md §9.
Strokes render in off-white (#FAF6F0) over the plum background drawable
(drawable/ic_launcher_background.xml = @color/ic_launcher_background, the
hue-rotated counterpart of Calendula's slate). The same vector fills the
<monochrome> slot so Android 13+ themed-icon launchers can recolour it.
Centering / scale:
- Scale 0.66 about the canvas centre (matching Calendula's ~2.8dp stroke
weight and generous padding).
- The eye centres on the task CARD, not the card+bloom bounding box (the
bloom is a small badge that overhangs the bottom-right). So vertically we
centre the card itself: its geometric centre is y=242.76, so we pivot the
Y-scale there and translate +13.24 to drop that centre onto the canvas
centre (256). Horizontally the card already sits centred, so X scales
about 256 untouched.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="512"
android:viewportHeight="108"> android:viewportHeight="512">
<path <group
android:fillColor="#00000000" android:pivotX="256"
android:strokeColor="#FFFFFF" android:pivotY="242.76"
android:strokeWidth="9" android:scaleX="0.66"
android:strokeLineCap="round" android:scaleY="0.66"
android:strokeLineJoin="round" android:translateY="13.24">
android:pathData="M36,55 l13,13 l25,-27" /> <!-- Task card (rounded square, opening at the bottom-right for the bloom) -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="20"
android:strokeMiterLimit="12"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M370.207 254.345V168.276C370.207 144.508 350.939 125.241 327.172 125.241H178.207C154.439 125.241 135.172 144.508 135.172 168.276V317.241C135.172 341.008 154.439 360.276 178.207 360.276H269.241" />
<!-- Check mark -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="20"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M193 245.569L231.822 287L320 199" />
<!-- Calendula bloom: petals around the centre -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M346.672 323.721C356.301 323.721 364.107 312.367 364.107 298.361C364.107 284.354 356.301 273 346.672 273C337.042 273 329.236 284.354 329.236 298.361C329.236 312.367 337.042 323.721 346.672 323.721Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M355.716 330.293C358.692 339.451 371.903 343.366 385.224 339.038C398.544 334.71 406.931 323.777 403.955 314.619C400.98 305.461 387.769 301.546 374.448 305.874C361.127 310.202 352.741 321.135 355.716 330.293Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M352.262 340.926C344.471 346.586 344.83 360.36 353.063 371.691C361.295 383.022 374.284 387.62 382.075 381.96C389.865 376.3 389.506 362.526 381.274 351.194C373.041 339.863 360.052 335.266 352.262 340.926Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M341.082 340.926C333.291 335.266 320.302 339.863 312.069 351.194C303.837 362.526 303.478 376.3 311.268 381.96C319.059 387.62 332.048 383.022 340.28 371.691C348.513 360.36 348.872 346.585 341.082 340.926Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M337.627 330.293C340.602 321.135 332.216 310.202 318.895 305.874C305.574 301.546 292.363 305.461 289.388 314.619C286.412 323.777 294.799 334.71 308.119 339.038C321.44 343.366 334.651 339.451 337.627 330.293Z" />
<!-- Bloom centre -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M346.672 342.742C351.924 342.742 356.182 338.484 356.182 333.232C356.182 327.979 351.924 323.721 346.672 323.721C341.419 323.721 337.161 327.979 337.161 333.232C337.161 338.484 341.419 342.742 346.672 342.742Z" />
</group>
</vector> </vector>

View File

@@ -129,6 +129,23 @@
<string name="smart_all">All</string> <string name="smart_all">All</string>
<string name="open_count">%1$d open</string> <string name="open_count">%1$d open</string>
<!-- Home: Today progress hero -->
<string name="home_today_progress">%1$d of %2$d done</string>
<string name="home_today_remaining">%1$d left</string>
<string name="home_today_all_done">All done 🎉</string>
<string name="home_today_empty">Nothing due today 🎉</string>
<!-- Home: Upcoming preview -->
<string name="home_upcoming_view_all">View all</string>
<string name="home_due_today">Today</string>
<string name="home_due_tomorrow">Tomorrow</string>
<!-- Home: search -->
<string name="home_search_hint">Search tasks</string>
<string name="home_search_clear">Clear search</string>
<string name="home_search_close">Close search</string>
<string name="home_search_empty">No tasks match “%1$s”</string>
<!-- Reminders --> <!-- Reminders -->
<string name="reminder_due_at">Due %1$s</string> <string name="reminder_due_at">Due %1$s</string>
<string name="reminder_channel_name">Task reminders</string> <string name="reminder_channel_name">Task reminders</string>

View File

@@ -50,6 +50,31 @@ class TaskMapperTest {
assertThat(task.isSubtask).isTrue() assertThat(task.isSubtask).isTrue()
} }
@Test
fun `recurrence is detected from rrule when is_recurring is absent`() {
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column;
// reading it alone would report the series as one-off and send its edits
// to the master row, re-anchoring the whole thing.
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")),
)
assertThat(task.isRecurring).isTrue()
}
@Test
fun `recurrence is detected from rdate alone`() {
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")),
)
assertThat(task.isRecurring).isTrue()
}
@Test
fun `a plain task is not recurring`() {
val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
assertThat(task.isRecurring).isFalse()
}
@Test @Test
fun `falls back to instance id when task_id missing, and list color when no task color`() { fun `falls back to instance id when task_id missing, and list color when no task color`() {
val task = TaskMapper.task( val task = TaskMapper.task(

View File

@@ -85,6 +85,57 @@ class TaskWriteMapperTest {
assertThat(values[Tasks.TZ]).isNull() assertThat(values[Tasks.TZ]).isNull()
} }
@Test
fun `all-day timestamps are pinned to UTC midnight`() {
// 2026-07-20T22:00Z — i.e. local midnight on the 21st in Berlin (UTC+2).
// The provider resolves all-day dates against UTC, so storing this as-is
// would land the task on the 20th for anyone reading it back.
val berlinMidnight = Instant.fromEpochMilliseconds(1_784_412_000_000L)
val values = TaskWriteMapper.taskValues(
TaskForm(title = "Holiday", listId = 1L, start = berlinMidnight, due = berlinMidnight, isAllDay = true),
tzId = "Europe/Berlin",
)
val dayMs = 24L * 60 * 60 * 1000
assertThat(values[Tasks.DUE] as Long % dayMs).isEqualTo(0L)
assertThat(values[Tasks.DTSTART] as Long % dayMs).isEqualTo(0L)
}
@Test
fun `timed timestamps are written untouched`() {
val at = Instant.fromEpochMilliseconds(1_784_412_345_678L)
val values = TaskWriteMapper.taskValues(
TaskForm(title = "Standup", listId = 1L, start = at, due = at),
tzId = "Europe/Berlin",
)
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L)
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L)
}
@Test
fun `duration is always cleared so it cannot collide with due`() {
// The provider validates the *merged* row and throws "Only one of DUE or
// DURATION must be supplied" if the stored row still carries a duration.
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, due = Instant.fromEpochMilliseconds(5_000L)),
tzId = "UTC",
)
assertThat(values.containsKey(Tasks.DURATION)).isTrue()
assertThat(values[Tasks.DURATION]).isNull()
}
@Test
fun `instance values drop list and parent, which an override cannot express`() {
val form = TaskForm(title = "x", listId = 4L, parentId = 7L, due = Instant.fromEpochMilliseconds(1_000L))
val values = TaskWriteMapper.instanceValues(form, tzId = "UTC")
assertThat(values.containsKey(Tasks.LIST_ID)).isFalse()
assertThat(values.containsKey(Tasks.PARENT_ID)).isFalse()
// …but still carries the edit itself.
assertThat(values[Tasks.TITLE]).isEqualTo("x")
assertThat(values[Tasks.DUE]).isEqualTo(1_000L)
}
@Test @Test
fun `completion sets status, percent and timestamp, un-completion clears them`() { fun `completion sets status, percent and timestamp, un-completion clears them`() {
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L) val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
@@ -97,6 +148,22 @@ class TaskWriteMapperTest {
assertThat(undone[Tasks.COMPLETED]).isNull() assertThat(undone[Tasks.COMPLETED]).isNull()
} }
@Test
fun `alarm carries every column the provider's validator demands`() {
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
assertThat(values[TasksContract.Properties.TASK_ID]).isEqualTo(12L)
assertThat(values[TasksContract.Properties.MIMETYPE])
.isEqualTo("vnd.android.cursor.item/alarm")
assertThat(values[TasksContract.Alarm.MINUTES_BEFORE]).isEqualTo(30)
// REFERENCE must be present and non-negative, ALARM_TYPE present and
// non-zero (0 is excluded from the provider's has_alarms count).
assertThat(values[TasksContract.Alarm.REFERENCE]).isEqualTo(TasksContract.Alarm.REFERENCE_DUE)
assertThat(values[TasksContract.Alarm.ALARM_TYPE]).isEqualTo(TasksContract.Alarm.TYPE_MESSAGE)
// property_id must be absent or the insert is rejected.
assertThat(values.containsKey(TasksContract.Properties.PROPERTY_ID)).isFalse()
}
@Test @Test
fun `local list uses the LOCAL account`() { fun `local list uses the LOCAL account`() {
val values = TaskWriteMapper.localListValues("Inbox", 0x123) val values = TaskWriteMapper.localListValues("Inbox", 0x123)

View File

@@ -0,0 +1,60 @@
package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Instant
class AllDayTimeTest {
private val berlin = ZoneId.of("Europe/Berlin") // UTC+2 in July
private val newYork = ZoneId.of("America/New_York") // UTC-4 in July
private val julyTwentieth = LocalDate.of(2026, 7, 20)
@Test
fun `an all-day instant is UTC midnight of its date`() {
val instant = allDayInstantOf(julyTwentieth)
assertThat(instant.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0L)
assertThat(instant.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
}
@Test
fun `an all-day date reads the same everywhere, unlike a timed one`() {
val allDay = allDayInstantOf(julyTwentieth)
// The whole point: zone must not change which day an all-day value denotes.
assertThat(allDay.calendarDate(allDay = true, zone = berlin)).isEqualTo(julyTwentieth)
assertThat(allDay.calendarDate(allDay = true, zone = newYork)).isEqualTo(julyTwentieth)
// Read as a timed value in New York it would slip to the 19th — the bug.
assertThat(allDay.calendarDate(allDay = false, zone = newYork)).isEqualTo(julyTwentieth.minusDays(1))
}
@Test
fun `toggling all-day off keeps the day and lands on local midnight`() {
val allDay = allDayInstantOf(julyTwentieth)
val timed = allDay.rebasedForAllDay(allDay = false, zone = berlin)
assertThat(timed.calendarDate(allDay = false, zone = berlin)).isEqualTo(julyTwentieth)
val local = java.time.Instant.ofEpochMilli(timed.toEpochMilliseconds()).atZone(berlin)
assertThat(local.toLocalTime()).isEqualTo(java.time.LocalTime.MIDNIGHT)
}
@Test
fun `toggling all-day on keeps the day the user was looking at`() {
// 2026-07-20T23:30 in Berlin — late enough that a naive UTC read slips a day.
val lateEvening = Instant.fromEpochMilliseconds(
julyTwentieth.atTime(23, 30).atZone(berlin).toInstant().toEpochMilli(),
)
val allDay = lateEvening.rebasedForAllDay(allDay = true, zone = berlin)
assertThat(allDay.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
}
@Test
fun `round-tripping the toggle is stable`() {
val original = allDayInstantOf(julyTwentieth)
val there = original.rebasedForAllDay(allDay = false, zone = newYork)
val back = there.rebasedForAllDay(allDay = true, zone = newYork)
assertThat(back).isEqualTo(original)
}
}

View File

@@ -0,0 +1,11 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" fill="white"/>
<path d="M370.207 254.345V168.276C370.207 144.508 350.939 125.241 327.172 125.241H178.207C154.439 125.241 135.172 144.508 135.172 168.276V317.241C135.172 341.008 154.439 360.276 178.207 360.276H269.241" stroke="black" stroke-width="20" stroke-miterlimit="12" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M193 245.569L231.822 287L320 199" stroke="black" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M346.672 323.721C356.301 323.721 364.107 312.367 364.107 298.361C364.107 284.354 356.301 273 346.672 273C337.042 273 329.236 284.354 329.236 298.361C329.236 312.367 337.042 323.721 346.672 323.721Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M355.716 330.293C358.692 339.451 371.903 343.366 385.224 339.038C398.544 334.71 406.931 323.777 403.955 314.619C400.98 305.461 387.769 301.546 374.448 305.874C361.127 310.202 352.741 321.135 355.716 330.293Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M352.262 340.926C344.471 346.586 344.83 360.36 353.063 371.691C361.295 383.022 374.284 387.62 382.075 381.96C389.865 376.3 389.506 362.526 381.274 351.194C373.041 339.863 360.052 335.266 352.262 340.926Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M341.082 340.926C333.291 335.266 320.302 339.863 312.069 351.194C303.837 362.526 303.478 376.3 311.268 381.96C319.059 387.62 332.048 383.022 340.28 371.691C348.513 360.36 348.872 346.585 341.082 340.926Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M337.627 330.293C340.602 321.135 332.216 310.202 318.895 305.874C305.574 301.546 292.363 305.461 289.388 314.619C286.412 323.777 294.799 334.71 308.119 339.038C321.44 343.366 334.651 339.451 337.627 330.293Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M346.672 342.742C351.924 342.742 356.182 338.484 356.182 333.232C356.182 327.979 351.924 323.721 346.672 323.721C341.419 323.721 337.161 327.979 337.161 333.232C337.161 338.484 341.419 342.742 346.672 342.742Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,5 @@
### Fixed
- Release automation now reliably mirrors each release to the Codeberg mirror
(signed APK + SHA-256 checksum). The 0.2.1 attempt failed when the release tag
had already been synced to Codeberg.

View File

@@ -0,0 +1,14 @@
### Added
- Reminders: Agendula now delivers your due reminders itself. A one-time setup
step explains this and asks for notification access, and a master switch in
Settings turns the whole thing off again.
- A Settings screen, from the gear on the overview: appearance and theme, which
fields the task form shows, your default list, and reminder defaults.
- The overview leads with Today — a progress ring showing how much of today
you've finished — followed by a live preview of what's coming up next.
- Search across every task, open or completed, from the top bar.
- A proper app icon.
### Changed
- A tidier top bar: no app title, with search and settings pinned to the right.

View File

@@ -0,0 +1,5 @@
### Fixed
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
release build stripped a constructor that the background-work scheduler needs
to open its database, and that happens before the app draws anything.

View File

@@ -0,0 +1,6 @@
### Fixed
- Releases reach the Codeberg download channel again. 0.3.1 published to
F-Droid but never appeared on Codeberg, so if you install from there — or
through Obtainium — this is the release that finally carries 0.3.0's
launch-crash fix. The app itself is unchanged from 0.3.1.

5
release-notes.md Normal file
View File

@@ -0,0 +1,5 @@
### Fixed
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
release build stripped a constructor that the background-work scheduler needs
to open its database, and that happens before the app draws anything.