Phase 5 of docs/OWN-STORE.md. The :provider module goes — 84 Java files,
14,555 lines, its <provider>, its two custom permissions, its 13
translated strings and its three dmfs runtime dependencies. Room has been
the default since the previous commit and every v0.3.x install has been
imported, so nothing reads it any more.
StorageMode.LOCAL is gone with it; OWN and EXTERNAL are what remain.
ProviderResolver narrows to what it was always really for — discovering
external providers — and answers null in OWN mode, where there is no
authority to resolve. Callers that need to tell that apart from "External
with nothing installed" ask mode(). ProviderStatus is unconditionally
READY in OWN mode: the permission gate only ever applied to External, and
that is now visibly true rather than a special case inside it.
A stored LOCAL is read as OWN rather than as an unparseable value. Left
to fall through to autoMode, someone who had explicitly chosen local
storage while also having OpenTasks granted would have been sent to
OpenTasks instead.
ProviderChangeReceiver's manifest filter drops our own authority — safe
now, because nothing of ours broadcasts ACTION_PROVIDER_CHANGED. In OWN
mode Room's InvalidationTracker covers foreground changes and nothing
outside the app can change our data. When SYNC.md phase 3 lands, the sync
worker must call ReminderScheduler.sync() itself; that is the replacement
for the broadcast and it belongs in the sync work.
lib-recur stays as a direct dependency and is still Apache-2.0 dmfs, so
the attribution is still owed — now as a normal third-party dependency.
provider/PROVENANCE.md is replaced by a postscript in STORAGE-DECISION.md
recording that the fork existed, why, and the one detail that still binds
us: tasks.org is DB 22 and has no is_recurring, so TaskMapper must keep
deriving recurrence from rrule/rdate.
BREAKING: the de.jeanlucmakiola.agendula.tasks authority and both custom
permissions are gone. Anyone who pointed DAVx5 or another app at that
authority loses it; External mode is the answer. Needs calling out in the
release notes.
Verified: the APK declares no ContentProvider, no custom permission and
no agendula.tasks authority, and carries no dmfs provider classes.
Phase 4 of docs/OWN-STORE.md. OneShotImport reads databases/tasks.db
directly — read-only, no provider, no ContentResolver — and writes it
into Room in one verified transaction. dmfs row ids are remapped in two
passes, because a parent can carry a higher _id than its child.
The archive happens before the import, not after, and the import always
replaces. That is what actually closes the crash window the plan's "flag
*and* rename" is meant to cover: renaming last leaves the flag unset with
tasks.db still in place, so the next launch imports a second copy. In
this order every kill point re-enters correctly.
Recurrence overrides are carried across as master_id/recurrence_id rather
than ignored. dmfs stores them as ordinary rows sharing their master's
_uid, so importing one as a second master would collide on the unique
index and abort the whole import.
autoMode now answers OWN, and a stored LOCAL reads as OWN — after the
import the dmfs file has been renamed away, so someone who chose local
storage explicitly must land on the store their data is now in.
StartupGate holds the first store read until the mode has landed and the
import has run; showing an upgrading user an empty app is the worst thing
this migration could do. The backup rules take the database with its WAL
sidecars and exclude the archive, and the app checkpoints on ON_STOP.
Joins phases 1 and 2 and covers phase 3's semantics. RoomTasksDataSource
implements all 14 seam methods; a StorageMode-routing delegate picks it
or the provider per call, since the mode is a setting the user can change
while the process lives.
There is no instances table, so a series is expanded at read time by
RecurrenceExpander and any RECURRENCE-ID override is substituted for the
occurrence it replaces. A timed series carries each occurrence's length
across; a due-anchored one has no start to offset from, so the anchor is
the due date — matching how the provider instantiated the same series.
Editing one occurrence writes a RECURRENCE-ID override sharing the
master's UID (RFC 5545 model (a)). The provider's Detaching.java forked a
brand-new task with its own UID instead — model (d), the one least
compatible with CalDAV. We inherited that without ever choosing it; this
is the choice.
TaskFormWriter states the completion rules directly instead of working
around the provider: progress and status now move together in both
directions, so a task can no longer strand itself "done at 75%".
TaskWriteMapper keeps the workarounds for External mode.
Deletes are hard when the list has no account and tombstones when it
does; master_id cascades, so a deleted series takes its overrides.
Phase 2 of docs/OWN-STORE.md, the engine half. RecurrenceExpander turns a
stored rule set into its occurrences at read time — no materialised
instances table, so none of its staleness bugs exist. Each occurrence is
returned as its RECURRENCE-ID anchor, which is what the seam now
addresses occurrences by.
Expansion is bounded two ways: the window end, and a hard occurrence
ceiling. The iterator is fast-forwarded to the window start first, so a
FREQ=MINUTELY series anchored years back doesn't scan millions of
instances to emit one.
Three things lib-recur 0.12.2 forced. RecurrenceSet.iterator injects the
start itself, so DTSTART is in the set for free and EXDATE can remove it
(RFC 5545 §3.8.5.3). Its window end is exclusive. And a floating UNTIL
against a zoned start throws, so the UNTIL's local fields are re-read in
the series zone — the vendored provider worked around the same thing via
TimeZone.getDefault(), which isn't deterministic.
Malformed RRULE/RDATE/EXDATE values are dropped, not thrown: a task with
an unparseable stored rule still has to appear.
38 tests. Multi-occurrence expansion has no provider behaviour to compare
against, so the reference is RFC 5545 directly — daily/weekly/monthly/
yearly, COUNT, UNTIL, a Europe/Berlin DST boundary, all-day series pinned
to UTC midnight, RDATE, EXDATE, and an unbounded rule hitting both bounds.
Phase 1 of docs/OWN-STORE.md. Four tables — task_lists, tasks,
task_alarms, accounts — with the indices, cascades and converters the
plan specifies, plus a DAO per table and the v1 schema JSON committed for
migration testing.
Masters and RECURRENCE-ID overrides share the tasks table, so the unique
index is on (list_id, uid, recurrence_id): an override shares its
master's UID, and a key without recurrence_id would reject exactly the
rows recurrence depends on. SQLite treats NULLs as distinct, so that
index only enforces the override half; the master half is intent, noted
where the index is declared.
PRIORITY is stored as the raw iCalendar integer rather than through the
Priority enum. Priority buckets 1..4 into HIGH, so a converter would
rewrite a server's PRIORITY:3 as 1 before it ever reached disk — the
bucketing belongs in the mapper. Status keeps its converter: that mapping
is total.
Two cascades the plan left unstated: deleting a list takes its tasks,
deleting an account only detaches its lists.
Instrumented tests cover read-back, the cascades and the unique index —
app/src/androidTest is new.
The own store expands recurrences itself, so lib-recur is a direct
dependency now rather than something :provider drags in. Still pinned at
0.12.2 — 0.16.0 removed RecurrenceSet.
scripts/make_import_fixture.py writes the tasks.db the one-shot import
will be tested against: the provider's DATABASE_VERSION 23 schema, seeded
with the cases the import has to get right (a task with no UID, a deleted
row, a recurring series, an all-day task, a subtask, an alarm property,
and a list under a real CalDAV account). The provider is being deleted, so
a fixture is the only way to keep testing against the schema it wrote.
Phase 0 of docs/OWN-STORE.md. Prepares the seam for the Room store while
the provider is still the store.
Task.id (the materialised instance row id) is gone; Task carries
occurrenceStart, its RECURRENCE-ID anchor, instead. updateInstance takes
(taskId, occurrenceStart, form) and AndroidTasksDataSource maps that back
to an instance row itself, so the provider path exercises the new
signature before Room exists.
Lazy-list keys move to Task.occurrenceKey. Two occurrences of one series
can appear in the same list once expansion is ours, and taskId alone
would collide there.
domain/Models.kt stops importing TasksContract — status, priority and
local-account constants now live in domain. StorageMode gains OWN as a
third value; LOCAL keeps meaning the dmfs provider until it is deleted.
Room 2.8.4 and room.schemaLocation added to the build.
The vendored dmfs provider was kept on the grounds that it hands us the
sync bookkeeping for free. The phase-1 sync audit measured that
bookkeeping and found most of it broken, absent, or unusable: _DIRTY not
set on delete, no home for a per-collection sync token, read-only
collections inexpressible, ACCOUNT_TYPE write-once so enabling sync is a
full migration, and cleanUpLists able to delete a user's lists after a
backup restore. Sixteen findings are provider-imposed rather than
platform- or protocol-imposed.
Costing the alternative showed the swap is far smaller than assumed.
TasksDataSource is already a 14-method, domain-shaped interface;
exactly one file above the data layer references TasksContract. The
work is a second implementation behind an interface built for it, not a
rewrite. Against ~5 weeks to build, owning the store removes 2.5-4
weeks from the sync plan, and 8,200 of the vendored 14,555 lines are
things we would never write - 23 migrations from a 2013 schema, 798
lines of full-text search the app has zero call sites for, and 1,581
lines of a type-safe layer over ContentValues that Room deletes.
External mode (OpenTasks, tasks.org) is unaffected and keeps every
file that describes somebody else's schema.
STORAGE-DECISION.md is the reasoning; OWN-STORE.md is the architecture
and the six-phase plan. :provider stays in-tree until phase 5 so
recurrence parity can be tested against it before it goes.
Also corrected here: the provider's JVM test count (51 -> 56, measured
from the test-results XML) and a fourth site of the debunked "switching
sync on is never a migration" claim, in StorageMode.kt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 3 of docs/STORAGE-AND-SYNC.md. Now that our own provider holds the data in
the app's private storage, a Local-mode user's tasks exist in exactly one place
and uninstalling deletes them — on Play, where most people will never have a sync
engine, that is the majority case. So export is a v1 feature, not a nicety.
One .ics per list, because a list is a CalDAV collection and that is the unit
other clients understand; folding everything into one file would flatten the
lists away, and list membership is not recoverable from a VTODO afterwards.
ExportWriter can put them in a folder (ACTION_OPEN_DOCUMENT_TREE) or a single zip
(ACTION_CREATE_DOCUMENT). No storage permission either way — SAF hands us a Uri
the user picked.
Two things needed care:
Export reads the tasks table, not the instances view the rest of the app reads
from. In the instances view a recurring task appears once per occurrence with its
times resolved and no rule attached, so exporting from there would write the same
task fifty times and lose the RRULE that generated them.
And local tasks have no UID. The dmfs provider only lets a sync adapter assign
one, so in Local mode every task arrives with _uid null — and a VTODO without a
UID is both invalid and un-mergeable, meaning a re-imported backup would
duplicate every task rather than match it. ICalendarWriter synthesises one from
the row id, stable across exports and tagged so it is recognisable as synthetic.
Times go out in UTC rather than with a TZID. Emitting TZID obliges us to emit a
matching VTIMEZONE with its transition rules, and a TZID referencing an absent
definition is what actually breaks importers. All-day values keep VALUE=DATE, the
only form that survives a timezone change intact.
The writer is pure Kotlin with no Android in it and is covered by 40 tests —
line folding counted in octets and never splitting a UTF-8 sequence, TEXT
escaping, forward references from a subtask to a parent later in the file, and
CRLF endings. An export is only as good as its ability to be read back, and
nothing about a malformed .ics is obvious until someone needs the backup.
The SAF plumbing is marked in the storage doc as floret-kit material. Kept
app-local for now on the kit's own stated principle of not extracting before a
second consumer exists; the seam is in place, so moving it is a file move.
Backend only — no UI yet; that comes with the frontend pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Steps 2 and 3 of docs/STORAGE-AND-SYNC.md. Agendula stops depending on a tasks
provider app being installed: it now carries one.
The module
New :provider — the dmfs task provider 1.4.2 (Apache-2.0, DB 23), vendored
in-tree, renamed to authority de.jeanlucmakiola.agendula.tasks and permissions
de.jeanlucmakiola.agendula.permission.*. It coexists with OpenTasks and
tasks.org rather than replacing them; nothing collides with org.dmfs.*, so both
can be installed at once. The contract shape is untouched — same tables, same
columns — because that is what our data layer and every CalDAV engine already
speak. We own the namespace it lives in, not the schema.
Vendored rather than depended on because the permission names are hardcoded in
the upstream AAR's manifest and cannot be renamed in a prebuilt artifact; in-tree
also satisfies F-Droid's from-source rule. provider/PROVENANCE.md records the
upstream commit and every deviation, each marked with an AGENDULA CHANGE comment
at the site so the list and the code cannot drift apart.
The change that matters most is the account cleanup. Upstream holds GET_ACCOUNTS
and deletes any task list whose account it cannot see. We dropped that permission
— we only ever need our own accounts, which are visible without it — but an
account we cannot see is indistinguishable from one that was removed, so left
alone the provider would quietly delete synced lists. Cleanup is now restricted
to account types this package authenticates itself, which is currently none.
ProviderAccountCleanupTest pins that, and answers open question 3: the local path
works with no account present at all.
Also required by targetSdk 36, none of which upstream faced at 29:
FLAG_IMMUTABLE on the notification PendingIntent, an inexact-alarm fallback so a
revoked SCHEDULE_EXACT_ALARM cannot kill the app on a timezone change, and an
explicit android:exported on the receiver.
Storage modes
ProviderResolver gains a StorageMode: LOCAL (our provider) or EXTERNAL (an
installed one). Not a third SYNCED value — synced is LOCAL with an account
attached, which is derived state, and modelling it as a separate store would
imply switching sync on is a migration. It isn't.
When the user has not chosen, the tell is whether we already hold an external
provider's runtime permission. That permission is dangerous-level, so it can only
be there because an earlier version asked and they agreed — the signature of an
existing Posture A user, who must not be dropped onto an empty store. Fresh
installs get local-first.
hasPermission now short-circuits for our own provider: same-uid access bypasses
the check outright, so ProviderStatus.NEEDS_PERMISSION can no longer fire in
Local mode. That was the work item the storage-and-sync doc called for. The
resolver's platform calls moved behind ProviderEnvironment so the decision — the
part that loses people their data if wrong — is unit-tested on the JVM.
Verified: 51 vendored provider tests pass, app tests pass, lintDebug and
assembleDebug clean. ProviderAccountCleanupTest skips on ARM64, where Robolectric
has no SQLite backend, and runs on x86_64 CI. Not yet exercised on a device.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corrects the tasks-provider interaction end to end (see 47cf99a). Merged as
step 1 of the storage-and-sync sequence in docs/STORAGE-AND-SYNC.md: it touches
the same permission flow that the :provider vendoring is about to change, so it
lands first.
The floret-kit submodule pointer keeps main's e047a2b, which already contains
the branch's 396e538.
# Conflicts:
# app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt
Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline. Ports the setup Calendula already runs on, adapted where Agendula genuinely differs.
**This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home.
### 1 · Workflows split by directory
Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync:
| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them |
The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API.
CI also gains three fixes: an explicit `SKIP_RE` skip-list that names the build-relevant files in the log, base-ref normalisation, and a fully-qualified `android-actions/setup-android` — Codeberg resolves bare `uses:` refs against `data.forgejo.org`, which does not carry that action.
### 2 · Three release-pipeline safety changes
- `detect` and the Renovate job get an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running these on the contributor-facing runner, without secrets.
- `detect` now reads tags from **Codeberg**, not from the Gitea instance it runs on. Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by the next sync until the Codeberg tag push propagates back — asking Gitea inside that window reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not.
- **The Codeberg publish step pushes the tag itself** instead of waiting for it to arrive by mirror. That wait was correct while Gitea mirrored *to* Codeberg; under Codeberg-canonical the mirror runs the other way and it would never resolve. Attaching the release to an already-pushed ref (no `target_commitish`) is what avoids the empty-bodied 500s, and the create call retries with backoff because Codeberg 500s on a tag it has only just received. The step stays **fail-loud**, not `continue-on-error` — it reported green through 0.2.1–0.3.2 while never once publishing, and that must not be possible again.
### 3 · Renovate
`renovate.json5` plus a Gitea-side job targeting Codeberg's API. `managerFilePatterns` covers **both** workflow directories, so the pinned Renovate image tag and the action versions in either file keep getting bumped.
Needs two new Gitea secrets: `RENOVATE_TOKEN` (Codeberg bot, repo read/write + PR scope) and `GITHUB_COM_TOKEN` (read-only github.com PAT, for changelog lookups).
### 4 · Weblate
A parity check (`scripts/check_translations.py`) runs on every PR without a path filter, so the required `Translations` status is always reported. Partial translations are expected, so `MissingTranslation` and `MissingQuantity` become informational — `ExtraTranslation` stays fatal. Agendula had no `lint` block at all, so the first locale to land would otherwise have failed the build.
**Settings → App language** now opens a picker carrying a "Help translate" header. That is why it drops floret-kit's `LanguagePickerRow` for a local row: the shared recipe has no `header` slot, and the framing is app-specific rather than a family primitive.
### 5 · Links repointed
In-app Source / License / report-issue URLs, F-Droid metadata, README (now with a Codeberg CI badge), and the docs.
`floret-kit` follows suit — `.gitmodules` points at `codeberg.org/jlmakiola/floret-kit`, so a clone no longer needs to reach the personal Gitea instance to resolve it. The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases still resolve.
### 6 · Housekeeping
Drops `release-notes.md` — a release-pipeline scratch file that got committed — and gitignores the five others the release job writes into the workspace.
### Not in this PR
The Codeberg → Gitea push mirror, the Weblate component, and the Codeberg bot account (all browser-side). Until the mirror is flipped, merging this does **not** reach the Gitea runner.
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/2
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Publish releases to the Codeberg mirror (signed APK + SHA-256 checksum)
as a direct-download channel. Bumps versionName to 0.2.1; the pipeline
mints the v0.2.1 tag on merge to main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port Calendula's current CI/release pipeline:
- ci.yaml: pull_request-triggered, change-scope classification
(docs/metadata-only PRs skip the Android build but still report a
green CI), and a reproducible-release invariant guard.
- release.yaml: the committed versionName is the source of truth — a
bump reaching main triggers the release, which builds, signs,
publishes to the F-Droid repo, then mints the vX.Y.Z tag + Gitea
release and mirrors it to Codeberg with the signed APK + SHA-256
checksum. workflow_dispatch runs the re-sign-only recovery path.
- Gitea releases are flagged as pre-releases while MAJOR is 0.
- build.gradle.kts: reproducible-release invariants (vcsInfo,
dependenciesInfo) + a releaseTest variant for the on-device gate.
- fastlane/ becomes the single source of truth for store metadata;
the localized F-Droid layout is generated from it at release time.
- Port scripts/, .gitea/ISSUE_TEMPLATE/, and rewrite docs/RELEASING.md
for the versionName-in-main model; fix stale references elsewhere.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Removes the doubled add idiom (top inline-add bar + FAB). A new "Bottom quick-add bar" setting (default off) picks one affordance per screen: off keeps the floating New task button everywhere; on shows a quick-add field pinned to the bottom of a real list (smart lists keep the button, as they have no single target list).
The bottom field floats on the brightest container tone with a shadow so it stands off the task cards, rides above the keyboard (ime ∪ navigation-bar insets), and drops focus when the IME hides so no stray cursor lingers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Collapse a task row's supporting info (priority, due, subtask progress,
list name) into one low-emphasis meta line instead of stacked filled
chips: priority is a tinted flag, the list name carries its list colour,
and dates are compact (formatDateTimeCompact).
- Expand subtasks in smart lists too, by lazily fetching a parent's full
child set on demand (TasksRepository.subtasks + the view model's live
lazyChildren), while per-list views stay query-free.
- animateItem on the rows so (un)expanding a group fades its subtasks
in/out and slides the rows below — clean whether the children were
already loaded or arrive a frame later.
- Sit the undo chip beside the FAB at its height rather than overlapping
or floating above it.
Bumps the floret-kit submodule for formatDateTimeCompact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-screen Modifier.predictiveBack scaled the leaving screen with a
graphicsLayer, but NavHost only composes one back-stack entry at a time, so
nothing was drawn behind the shrinking screen — the gesture revealed the white
window background instead of the previous screen.
Drive predictive back through navigation-compose instead: it composes the
destination being revealed and seeks popExit with the gesture. popExit is now a
scaleOut + fade peek, so the leaving screen shrinks to reveal the real previous
screen held static. Drops the predictiveBack modifier from the destinations and
Settings (its inner section BackHandler is unchanged). The kit's predictiveBack
stays for overlay-style screens.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the in-app AboutCard, LanguageRow and languageLabel in favour of the
floret-kit recipes; the app still supplies its logo, strings, links and row
icon. Bumps the submodule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the inline tonal amount field in the reminder picker with floret-kit's
DialogAmountField. Bumps the submodule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the inline expandVertically/shrinkVertically + fade in the reminder
picker, task edit form and settings with the shared transitions — which adds
reduced-motion support and the Expressive springs. Bumps the submodule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Back was a full-width horizontal slide-off — too heavy. Replace it with
floret-kit's Modifier.predictiveBack on each pop-able destination (task list,
detail, edit) so the leaving screen scales/rounds with the gesture and the held
screen is revealed unmoved; NavHost now does no pop animation. Settings applies
it at the hub only (enabled = section == null) so its inner section back still
returns to the hub. Forward stays a calm fade-in.
Bumps the floret-kit submodule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use components' OnboardingScaffold / BenefitRow / OnboardingSpace and drop the
in-app copies; keep Agendula's own SquircleHero (now in OnboardingHero.kt).
Bumps the floret-kit submodule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consume two newly extracted floret-kit modules and drop the in-app copies:
- core-reminders: ReminderUnit / decomposeReminderMinutes, the ReminderOverride
model and the override codec. SettingsPrefs keeps its own DataStore wiring and
Agendula's ':' codec dialect; ReminderFormatting/ReminderLeadPicker keep the
app's R.string labels and presets.
- core-locale: AppLanguage. Deleted the app copy; SettingsScreen now passes
R.xml.locales_config to supportedTags().
Bumps the floret-kit submodule to include both modules.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Agendula now sources the collapsing settings/sub-screen scaffold and the
full-screen single-select picker (FullScreenPicker/OptionPicker) from the kit's
components module. Deletes the app copies; repoints SettingsScreen imports and
adds the FullScreenPicker import in ReminderLeadPicker. Submodule re-pinned.
ReminderFormatting stays app-local (needs a label-callback API — separate pass).
Clean composite build + tests + lintDebug + debug assemble green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 (components). Agendula now sources its grouped-row primitive
(GroupedSurface/GroupedRow + Position/positionOf), InlineTextField, OptionCard,
and pastelize() from the kit's components module instead of its own ui/common
copies. These were Agendula's versions, taken as the canonical family reference
(design sign-off); Agendula keeps its own identity chips (ListColorChip/
ListNameChip task glyph, PriorityChip) which now import the shared pastelize.
- Delete app copies of GroupedList/InlineTextField/OptionCard; drop the local
pastelize from ListChip. Repoint all imports (cross-package + same-package +
one inline FQN) to de.jeanlucmakiola.floret.components. Submodule re-pinned.
Clean composite build + tests + lintDebug + debug assemble green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3 (identity layer). AgendulaTheme is now a thin wrapper over the kit's
FloretExpressiveTheme(lightScheme, darkScheme, …): the shared mechanics
(dynamic colour, light/dark, standard motion scheme) live in floret-kit, while
Agendula keeps its own identity — the seed-derived AgendulaLight/DarkFallback
schemes and AgendulaTypography (unchanged in Color.kt/Type.kt). rememberNavSlideSpec
now comes from the kit; the app's AgendulaTransitions.kt is removed and the
NavHost import repointed. Submodule re-pinned to the identity kit commit.
Clean composite build + tests + lintDebug + debug assemble green; substitution
confirmed (de.jeanlucmakiola.floret:identity -> project :floret-kit:identity).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds crash reporting to Agendula by drawing the kit's new core-crash module
(first Android-library module in floret-kit), and finishes the core-time wiring
that a Phase 0 staging slip left uncommitted on main.
core-crash (NEW capability — Agendula had none):
- CrashReporter.install() in AgendulaApp captures uncaught exceptions on-device
(allowlist-only report, chains to the platform handler, uploads nothing).
- MainActivity routes to a standalone CrashReportActivity on a startup crash-loop,
and surfaces a single captured crash as a dialog on next launch; markHealthy on
resume. App label + issue-tracker URL flow in via CrashConfig.
- Thin app-side CrashReportActivity uses AgendulaTheme; the reusable machinery
(reporter, dialog, submit, config) lives in the kit. Submodule re-pinned to the
core-crash kit commit.
Completes Phase 0 (was only partially committed in ec7b696):
- Repoint DayWindow + Instant formatting imports to de.jeanlucmakiola.floret.time
(the app copies were deleted in ec7b696 but the imports/includeBuild/dependency
were never staged, leaving main non-building).
- settings.gradle.kts includeBuild("floret-kit"); app depends on core-time.
Also fixes the CI checkout YAML: the submodules block was mis-indented under
'uses:' (invalid step mapping) — dedented to a proper sibling.
Clean composite build + unit tests + lintDebug + debug assemble green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 0 of the shared-kit plan: floret-kit is embedded as a git submodule and
wired in with Gradle includeBuild("floret-kit"), so the app depends on
de.jeanlucmakiola.floret:core-time and Gradle substitutes the local source
module (built from source — F-Droid-safe). Proves the submodule + composite-build
pipeline before any design layers move.
- Remove app copies of DayWindow + Instant date/time formatting (now in
core-time); repoint imports to de.jeanlucmakiola.floret.time.
- DateTimeField gains an explicit import (was same-package).
- CI checkouts fetch submodules recursively.
- Clean composite build + unit tests + debug assemble green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Floret is promoted to the family / design-language (shared-kit) name; the
tasks app itself becomes Agendula (de.jeanlucmakiola.agendula) — agenda
('things to be done') + Calendula's -ula, a twin of the Calendula name.
Renames the package, namespace, applicationId, rootProject.name, app_name,
FloretApp/FloretNavHost/FloretTransitions classes, theme, F-Droid metadata
dir, CI artifact name, and docs. The botanical word 'florets' is preserved in
the name-origin prose, which is rewritten to Agendula's etymology. Clean
build + unit tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reminders onboarding & polish, plus the Settings screen (landed early).
- One-time reminder onboarding gate after the provider grant
(ReminderOnboardingScreen + OnboardingScaffold), requesting
POST_NOTIFICATIONS on API 33+; choice recorded in prefs.
- Settings screen, structured after Calendula as a category hub with
sliding sub-screens (CollapsingScaffold + grouped rows + full-screen
OptionPicker): About card, Appearance (theme, dynamic colour), Task
form (default edit-form fields, default list, add-a-subtask-row
opt-out), Reminders, Language, and Report a problem. Reached via an
expressive shaped action button (MaterialShapes.Cookie4Sided) on the
lists overview. Back from a sub-screen returns to the hub.
- Reminders: master enable toggle (gates the whole engine; re-requests
the notification permission), a default lead time with a Custom
amount/unit editor, an Android-12 exact-alarm status row, and
per-list overrides of the default (Inherit / None / custom lead),
honoured by ReminderScheduler per task.
- Per-app language (AppLanguage + locales_config.xml + appcompat
locales service); add-a-subtask-row opt-out wired into the task list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote [Unreleased] → [0.2.0] in the changelog (M2 UI + M3/M4 + fixes) and
bump the dev-default version to 0.2.0 / 200. The pushed tag drives the released
version per docs/RELEASING.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M3 detail/edit polish:
- "Progress" slider on the edit form writes Tasks.PERCENT_COMPLETE (clamped
0-100, 5% detents); status stays owned by the complete toggle.
- Conflict-safe saves: updateTask re-checks the provider's last_modified against
the value captured when the form loaded and throws TaskConflictException; the
editor offers overwrite-or-cancel instead of clobbering an external change.
M4 subtasks (UI):
- Reparent: a "Parent task" picker files a task under any top-level task in its
list (or "None" to promote it); candidates stay top-level to keep nesting one
level deep. Switching list clears the now-invalid parent.
- Tapping a subtask opens its own detail (a new TaskDetail entry, own VM).
Tests: percent clamping, parent-id write, populatedFields reveal logic.
Docs: ROADMAP M2/M3/M4 reconciled to the codebase; CHANGELOG [Unreleased].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render the subtasks group on every task, not just ones that already
have children, so the inline add field is available from the start.
Hide the done/total count until there's at least one subtask.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>