- Back now closes the drawer instead of the app: a `BackHandler` inside the sheet, enabled while the drawer is open. The four host screens (Month/Week/Day/Agenda) pass their `drawerState` in.
- Header and section labels use `GroupedListInset` instead of a hardcoded 28.dp, so "Calendula", "View" and "Calendars" share the left edge of the rows below them — same as the Settings screens. The account labels and placeholders in `CalendarFilterList` follow.
- Plain leading icons now sit in a 40.dp box, matching `CalendarColorChip`'s footprint, so view rows, jump-to-date, Settings and the calendar rows all line up on one vertical axis.
Closes#114
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/121
CI already skips the Android build for docs-only pull requests, and that part works — a README-only PR reports green in ~11s. The skip-list was just too narrow to catch a realistic docs PR.
PR #96 is the example: a contributing guide, docs corrections and issue templates. It ran the full lint + test + assemble four times, over exactly two files Gradle never reads — `.forgejo/ISSUE_TEMPLATE/config.yml` and `.gitignore`. Added to the list: issue templates, `.planning/`, `licenses/`, `renovate.json5`, `.gitignore`, `.gitattributes`, `.editorconfig`.
It stays a skip-list rather than a build-list, so an unfamiliar path still builds by default; the workflows themselves, `.gitmodules` and `scripts/` are deliberately not skippable. The step now also prints which files forced the build, so the next "why did my docs PR build?" is answerable from the log.
One latent bug alongside it: `github.base_ref` is normalised against a `refs/heads/` prefix. Arriving in full-ref form would fail the merge-base lookup and quietly degrade the guard into "always build" — the failure mode this PR is fixing, but permanently and invisibly.
Verified by replaying the new pattern over real history: PR #96 and #97 now skip; the forge migration (workflows), the search fix (app code) and a Weblate translation merge still build.
No issue — reported directly.
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/98
The screenshot strip linked `05-edit.png`, which doesn't exist — replaced with the real agenda screenshot, and the unused day-view shot is now shown too (six screenshots, all present).
Install is now one section per channel — official F-Droid, self-hosted F-Droid repo, Codeberg release / Obtainium, Google Play (coming soon), build from source — with an overview table up front and the previously missing "Get it on Obtainium" badge in the header. The badge links to the Codeberg releases page rather than an `obtainium://` deep link, because Forgejo's markdown sanitiser strips custom URL schemes.
The Play section notes that Play builds carry Google's signing key (so switching channels needs an uninstall) and asks for closed-testing testers.
Also corrects two stale claims: the UI is no longer German-and-English only (nine translations exist), and the language list now says so.
No issue — reported directly.
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/97
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.
**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 · floret-kit moved
Mirrored to `jlmakiola/floret-kit` (6 branches, 3 tags, every SHA verified identical) and `.gitmodules` repointed. This is what actually unblocks contributors — a clone previously could not resolve its submodule without reaching the personal Gitea instance.
The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases (including F-Droid reproducible rebuilds) still resolve.
### 2 · 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.
### 3 · Two release-pipeline safety changes
- `release.yaml`'s `detect` gets 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 the release pipeline 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.
### 4 · Links repointed
In-app Source/License links, README badge, both F-Droid metadata files. **`Repo:` in `docs/fdroid-official/` deliberately stays on Gitea** — it keeps receiving `main` and every tag, so it remains a complete build source, and leaving it alone means no fdroiddata MR and no reproducible-build risk.
### Not in this PR
Renovate + Weblate repointing, and the Codeberg → Gitea push mirror (browser-side).
Supersedes Gitea PR #104.
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/86
Adds a fourth link to the about card in settings, next to source and licence.
Play requires the privacy policy to be reachable from inside the app, not just from the store listing, for anything that touches calendar or contact data. Points at jeanlucmakiola.de/calendula/privacy.
Only the base string is added — Weblate owns the translations.
Reviewed-on: #103
Age came back `unknown` for every dependency after #98. That's the `internalChecksFilter: "none"` from that PR doing what it says on the tin — and rather more.
### Why
`lib/workers/repository/process/lookup/filter-checks.ts` short-circuits on `none`:
```js
if (internalChecksFilter === 'none') {
release = sortedReleases.pop(); // returns here
} else {
for (let candidateRelease of sortedReleases.reverse()) {
...
const updatedCandidateRelease = await postprocessRelease(...)
```
`postprocessRelease` is the only caller that fetches a Maven artifact's `Last-Modified` header (`lib/modules/datasource/maven/index.ts:222`), so skipping the loop leaves `releaseTimestamp` unset. That empties the Age column — but it also silently voids `minimumReleaseAge` and the `renovate/stability-days` check, since both compare against that same timestamp. The cooling-off tiers were decorative.
### Fix
`flexible` keeps the intent — still prefers a version that has cleared its window, but opens the PR at the newest candidate when none has — while running the loop that populates the timestamps.
Verified with a local `--platform=local --dry-run=full`:
| dependency | new version | age |
| --- | --- | --- |
| `androidx.compose:compose-bom` | 2026.06.01 | 24 d |
| `androidx.compose.material3:material3` | 1.5.0-alpha24 | 10 d |
| `androidx.work:work-runtime-ktx` | 2.11.2 | 122 d |
| `com.android.application` (AGP) | 9.3.1 | 2 d |
| `gradle` | 9.6.1 | 29 d |
Only `ghcr.io/renovatebot/renovate` stays unknown, because the docker registry serves no timestamps at all — the run marks all 176 of its tags pending for that reason, and `flexible` is what still lets that PR through.
`Pending` rejoins the table: under a flexible filter it names the newer version being held back, rather than leaving it invisible.
### Also
Migrates the Gitea workflow manager off the deprecated `fileMatch`. Renovate's config migration was rewriting it to `managerFilePatterns` (delimited regex) on every run and warning about it; the dry run confirms the manager still picks up `.gitea/workflows` afterwards.
Validated with `renovate-config-validator` against the pinned 43.232.0.
Reviewed-on: #100
Three gaps in the Renovate setup, all about having enough in front of you at review time.
### Age reads UNKNOWN
`config:recommended` already brings in `mergeConfidence:age-confidence-badges`, so the Age column existed — it just rendered grey. Fetching the badge SVGs from `developer.mend.io` directly shows why: Mend's index covers Maven Central but has nothing for Google's Maven repo, so every androidx/compose coordinate is blank. No token changes it; the JSON API behind the badges answers 401 for everyone.
| coordinate | registry | age badge |
| --- | --- | --- |
| `androidx.compose:compose-bom` | Google Maven | UNKNOWN |
| `androidx.core:core-ktx` | Google Maven | UNKNOWN |
| `com.google.truth:truth` | Maven Central | 2y |
| `org.jetbrains.kotlin:kotlin-stdlib` | Maven Central | 1y (confidence high, passing 99%) |
Age is now computed from `newVersionAgeInDays`, which Renovate derives itself to evaluate `minimumReleaseAge` — Google Maven serves `last-modified` on its POMs, so it's populated where Mend is blank, and the number agrees with the tiers it's read against. Mend keeps the Confidence column, which still resolves for the Maven Central half (Kotlin, Gradle, AGP, the test stack).
### Empty release notes
We run against Gitea, but the packages are *released* on GitHub, so changelog lookups were going out unauthenticated against a 60/h limit. `RENOVATE_GITHUB_COM_TOKEN` lifts that.
**Needs a secret before it does anything:** a github.com PAT with **no scopes ticked**, added as repo secret `GITHUB_COM_TOKEN`. Until then the var resolves empty, which is exactly today's behaviour.
### Nothing expressed how settled a release is
Cooling-off scaled by blast radius: 30 days major, 20 minor, 10 patch/digest.
Deliberately advisory. Renovate's default `internalChecksFilter: strict` would suppress the PR outright until the version aged in; `none` opens it at the highest version immediately, so merging ahead of the window stays a decision rather than a wait. A too-young release still gets a yellow `renovate/stability-days` check — `setStability` computes that from `minimumReleaseAge` + `releaseTimestamp` independently of the filter — and with `automerge: false` nothing acts on it.
### Notes
Validated with `renovate-config-validator` against the pinned 43.232.0. Config is read from the default branch, so the open bump PRs keep their current tables until the next run after this merges.
Reviewed-on: #98
The Codeberg mirror step pushes the tag, then immediately POSTs the release
for it — but Codeberg 500s when the release request outruns its indexing of
the just-pushed ref, and with only one attempt that single 500 skipped the
mirror every release (the same POST succeeds seconds later, as a manual retry
confirmed for v2.16.0). Wrap the create/update in a backoff retry loop that
PATCHes in place if a release already exists, so a transient 5xx no longer
loses the mirror. Step stays best-effort (continue-on-error).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidate the release under a single 2.16.0 heading dated today and fold
in the work that had accumulated in Unreleased. Add the entries the log had
been missing as PRs merged: app-name toggle (#44), the Arabic/French/Italian
translations, the custom recurrence picker redesign + end-date fix (#42),
multi-day agenda events (#83), and dark-theme picker bar icons (#70).
Regenerate the fastlane per-version changelog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Weblate merge added a values-ar translation (~50% complete, RTL —
the app already declares supportsRtl). Add the matching <locale> line so
Arabic is selectable in both the in-app picker and the system per-app
language settings. Russian and Portuguese stay out for now: at 12% and
<1% they're below the usable bar (the lowest currently-offered language,
zh-CN, sits at ~24%).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Weblate translation of a <plurals> may not fill every CLDR quantity
form its locale defines — e.g. Arabic's import_reminder_prompt_body_existing
omits the "zero" form — which lint flags fatally as MissingQuantity and
aborts the CI build. The missing form falls back to "other" at runtime, so
this is the same partial-translation case we already accept for
MissingTranslation; downgrade MissingQuantity to informational alongside it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mail clients, browsers and file managers frequently label a calendar
attachment as a generic download (application/octet-stream) rather than
text/calendar, so the MIME-typed VIEW filter missed them. Add a dedicated
filter that matches those by .ics/.vcs extension via pathPattern — kept
separate so the path constraint can't narrow the reliable MIME-typed filter.
The import handler already ignores the declared MIME, so a let-through file
imports normally. Best-effort: reliable for file:// and content:// whose path
carries the name; nameless content:// URIs still fall back to the MIME filter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Other apps' "Add to calendar" / "Save to calendar" actions commonly fire the
canonical insert intent — ACTION_INSERT with setType("vnd.android.cursor.item/
event") — the singular *item* MIME type (Android's own docs example; used by
e.g. DB Navigator). Calendula advertised INSERT only on the *dir* MIME type, so
it never matched: absent from the chooser, and a silent no-op when it was the
only calendar app installed.
Add the item-typed INSERT to the events filter, plus ACTION_INSERT_OR_EDIT (the
third "add to calendar" action AOSP and Google Calendar register). The runtime
parser already treats any ACTION_INSERT as create; teach insertFormOrNull /
editEventKeyOrNull about INSERT_OR_EDIT so an id-less one is a create and an
id-carrying one opens the edit form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
floret-kit#3 merged to main; move the pointer from the fix branch commit
to the main merge commit. No code change — same tree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump floret-kit to the fix that drives each picker Dialog window's
status/navigation-bar icon appearance from the runtime theme. The picker
Dialog owns its own Window, seeded from the XML theme rather than the
activity's runtime edge-to-edge state, so in dark theme the status-bar
icons went black and near-invisible on the dark picker (Codeberg #70).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scrolling styles navigate by scroll position and never set the paged
month, so it sat wherever paged navigation last left it — today's month
on a fresh open. Switching to a paged style, or reseeding the other
scrolling style's list state (both read off it), snapped back there
instead of holding the month on screen.
Track the paged month to the visible month while a scrolling style is
up, realigning the split selection alongside it so landing on Split
shows a live day in the month you were viewing rather than a stale,
off-month one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tapping a greyed leading/trailing day in the split grid follows the
selection to that day's own month, but selectDate never set the slide
direction — so the incoming page reused whatever the last swipe left in
slideDir and could travel backwards while navigation moved forwards.
Wrap selectDate so a tap that crosses months points slideDir at the
month it is actually heading to.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chooser showed a per-style blurb on every row and a generic feature
summary under the preview. Move the selected style's own blurb under its
preview instead, drop the generic summary, and reduce the rows to a single
line — the preview is now bigger (280dp, wider) in the space that frees up.
The blurbs now stand alone: Dense's copy referenced Continuous ("the same
endless scroll…"), which made no sense once each shows in isolation. Rename
the two vertical styles to say what they do — Continuous → "Scrolling months",
Dense → "Seamless weeks".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The outline was the last piece with nothing to morph against, so it could
only fade in place while everything around it moved. The expanded grid
draws no outline by design — once the cells carry real event bars it is one
mark too many — which left it partnerless.
Same answer as the multi-day dots: give it an invisible stand-in over the
same cell in the expanded grid. It draws nothing and exists only to be the
other end of the morph, so the outline now rides its cell between the two
layouts.
Tagged only while it is the selected day. Tagged unconditionally it would
put a key on all 42 cells against the single partner on offer, and the
other 41 would fly in from the layout origin — the failure the dots just
had. The stand-in carries the same padding as the outline's own bounds, or
it would arrive at the wrong size, and it sits in its own layer rather than
inside the cell pill, which is itself tagged and would make it travel twice.
MonthGrid takes `selected` again, but for an anchor rather than a mark; the
other styles pass nothing and compose no extra layer at all.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A multi-day event has a dot on every day it covers but only one bar, so the
dots away from its start had no counterpart. Untagged they stood still and
faded; tagged without a partner they flew in from the top of the grid.
Neither is an animation — the last commit swapped one for the other.
The expanded grid now places an invisible slice of the bar in each further
column it spans, keyed to that column's day. Every dot has a real place to
come out of and go back into, at its own column, so it drops out of the bar
above it instead of appearing from nowhere.
That makes the seating's anchored flag pointless again — every dot has a
partner now — so it and its tests come back out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dots on the middle of a multi-day run flew in from the top of the grid
instead of fading where they stand.
They were meant to be left unmatched — a bar is drawn once per row, from
the column it starts in, so only that day's dot has a counterpart. But
"unmatched" was implemented as tagged-and-unmatched, which is a different
thing: a shared element entering with no partner has no bounds to start
from, so it animates in from the layout origin. The top.
Seating now says which day a bar is actually drawn from, and only that dot
carries a tag. The rest are plain content and fade in place, which is what
the comment claimed they did all along.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Frame-by-frame, the marks were not fading in late — they were invisible
for the first half of their journey and then appearing already near the
destination, row by row from the top down.
A mark's two homes are in different rows: the dot for the 20th sits a third
of the way down the compact grid, its bar most of the way down the expanded
one. The events box clips to its own row, so a mark arriving there is out
of bounds — and undrawn — until it crosses in. The lower the row, the
further it travels and the later it showed, which is exactly the order the
capture shows them appearing in.
Rendering in place rather than in an overlay is what subjects marks to that
clip, and that is worth keeping — it is what puts the whole grid in one
z-order. So the clip yields instead, and only while pieces are in flight;
at rest it still stops a bar spilling into the row below.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Travelling pieces were painted into an overlay above the entire tree —
Compose's default, and right for a thumbnail flying across a screen, wrong
for a grid changing shape in place. Everything tagged left the tree's
z-order, so every untagged neighbour spent the transition buried under it.
Lifting each of those out in turn — the selection outline, then both "+N"
markers — fixed the burying and bought something worse: they floated over
the grid on a layer of their own, out of step with what they belong to.
Three pieces needing the same escape hatch was the tell that the overlay
itself was wrong here.
Nothing renders in the overlay now. One z-order, one clip, so the grid
reads as a single surface changing shape rather than a stack of pieces
sliding past each other. The outline goes back to being plain content that
sits above its own pill the ordinary way, and morphOverlay is gone.
The "+N" markers become a matched pair instead of an orphan. The compact
grid writes the count as text and the expanded one as a row of dots, but
they stand for the same events on exactly the same days — both seat three
and overflow the rest — so they were always two halves of one marker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two reasons a mark arrived a beat late instead of animating.
The dots and bars carried morphBounds *inside* their own size modifiers, so
the shared bounds had nothing to drive: the dot stayed pinned at 5dp however
far its bar had travelled, and the bar sat at full width from the first
frame, each snapping into place only once the transition ended. The growth
was being clamped away. morphBounds now sits outside the width/height on
both sides. A bar's offset stays outside it — that is where the bar sits,
not how big it is.
The "+N" overflow markers were untagged, so they spent every transition
painted over by the overlay the tagged pieces draw into, exactly as the
selection outline did. Both the compact grid's and the expanded grid's are
now lifted into it.
The outline itself moves to the fast effects spec, as asked: it is a small
mark that only ever appears or disappears, and at the shared pace it
lingered after the thing it marks had already moved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sending the outline to the shared-transition overlay stopped it hiding
behind its own cell, but it also lifted it out of the layer AnimatedContent
fades the outgoing page with — so it stopped fading at all. It stood at
full strength for the whole expand or collapse and then blinked out at the
end, which is the opposite half of the same bug.
The fade is reapplied on the lifted content's own layer, so it travels with
it. That belongs in morphOverlay rather than at the call site: anything
sent to the overlay loses the parent's fade the same way.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The handle advertised a gesture it didn't answer to — the drag lived on the
grid alone, so the one part of the screen that says "pull me" was the one
part that ignored being pulled. Grid and handle now drag as a single
surface; the pane stays outside it, since it scrolls and is full of
tappable rows. Tapping the handle still toggles: a parent drag and a child
clickable coexist the same way they do in any scrollable list.
The selection outline was still flickering through an expand or collapse,
and being untagged was not enough to explain it. Tagged pieces paint into
an overlay above the whole regular tree, so the outline — correctly left in
that tree, to keep the collapsed cell's bounds — spent every transition
hidden behind its own morphing pill and reappeared the moment it ended.
It now renders into that same overlay while keeping its own bounds, which
is what the untagged layout was for in the first place.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The split style's pull-to-expand fires the moment the drag clears its
threshold, and that turned out to be the better feel — so paging now does
the same everywhere: month, week and day.
Waiting for the lift meant the page sat still under a finger that had
already travelled far enough to ask for it, and the answer only arrived
once you let go, which reads as the view being slow rather than as a
deliberate commit. The trade is that a drag can no longer be taken back by
dragging the other way; once you have moved 24dp deliberately you meant it,
and the page you left is one swipe back.
The three views had three copies of the same detector, so this extracts one
rememberCalendarPageSwipe and the threshold constant beside it. It stays
horizontal-only on purpose: the week and day timelines scroll vertically
underneath, and a two-dimensional detector would claim those drags before
the inner scroll saw them. The month keeps its own axis-locking detector
because the split style needs the vertical axis, and now shares the
threshold.
Also fixes the selection outline shivering rather than fading. Its animated
alpha was read while composing the cell, so every frame of the fade
recomposed all 42 cells at once — the fade was driving the whole grid
through recomposition to change one colour. It is now held as a State and
read inside a drawBehind, which keeps it in the draw phase where it touches
nothing but pixels. That means drawing the rounded rect by hand instead of
Modifier.border, so the corner radius is named alongside CELL_SHAPE to stop
the two drifting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drawn on the cell's morphing pill, the outline rode its shared bounds — so
collapsing painted it at the *expanded* cell's size and shrank it down, a
full-height outline flashing over a grid that no longer had full-height
cells. The same shape of mistake as the marker that used to cross the
outgoing month during a page turn.
It gets its own untagged layer, laid out where the collapsed cell actually
is, and fades in there. That is the only size it is ever true at.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The trigger was what felt slow, and firing on the threshold instead of on
release already fixed that. Speeding up the animation on top of it was a
second change to the same complaint, and not one that was asked for — the
morph goes back to the shared calendar slide and fade specs, so it moves at
the same pace as every other transition in the app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three things from the first on-device look.
The selection outline is gone from the expanded grid. Once the cells carry
real event bars it was one mark too many, and it had nothing to say —
every day there is one tap from being selected, and tapping is what closes
the view. MonthGrid/MonthWeekRow lose the parameter again.
Expand and collapse now fire the instant the drag clears the threshold
instead of on release. Waiting for the lift left the screen inert under a
finger that had already asked for the thing, which read as the gesture
being slow to take rather than as a deliberate commit. Paging still waits,
because there the gesture is genuinely undecided until you let go — you can
drag back and settle on a different month. Between expanded and collapsed
there is nothing to change your mind about.
The morph itself runs on the motion scheme's *fast* specs rather than the
page slide's default ones. Paging is a spring under one whole page and
wants room to settle; this answers a drag already committed to, and with a
hundred-odd pieces moving at once a long settle reads as lag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expanding replaces the whole screen, so it is a place you can be, and every
other place in the app can be backed out of. Without this, back left the
Month view entirely and coming back found it still expanded.
Declared deeper than CalendarHost's view-stack handler, which is what makes
it take precedence while it is enabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The expand cross-faded two unrelated grids, so a day's cell jumped from a
46dp row near the top of the screen to a full-height one halfway down and
the dots simply vanished where bars appeared. It read as a swap, not as the
month opening up.
Tag the pieces that mean the same thing on both sides and let Compose carry
them: each cell pill grows, its number rides along, and every dot travels
out to the bar it stood for — which only works because the dots are now
seated by lane, so each one has exactly one bar to become.
The tags travel as a composition local with a null default, so the paged,
continuous, and dense styles — which share these same row and cell
composables — provide nothing and pay nothing. Reduced motion provides
nothing either, leaving the plain cross-fade underneath.
A multi-day bar is anchored on the day it starts in its row; the dots on
the days it merely covers are left unmatched and fade where they stand
while the bar sweeps out over them. Only one of them could become the bar
without the others teleporting into it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drag the compact grid down and the day pane gives way to the month drawn
the paged style's way — real bars and pills, not dots; drag up to bring the
pane back. Split could show you which days were busy, or one day in full,
but never the month's events at once without a trip to Settings.
The month swipe grows a second axis to do it, so the two are locked to one
decision per gesture: independent horizontal and vertical detectors would
each see their own component of a diagonal drag and both fire. Paging keeps
ties, so an ambiguous drag still reads as it did.
A tap in the expanded grid picks the day and drops back, which makes it a
chooser you dip into rather than a mode you can get stranded in — so the
grid needs the selection outline too, which MonthGrid now takes optionally.
Expansion is state, not a preference: persisted, someone would expand it
once and later find their Split style changed with nothing to explain why.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dots gathered a day's *distinct colours*, which meant dot i stood for
no particular event and two events sharing a calendar collapsed into one —
undercounting the day.
Seat them off the same lane assignment the paged grid draws from, so a dot
and the bar in that lane are the same event. That fixes the count, and it
is what will let one morph into the other when the grid expands.
The cap is now MAX_EVENT_ROWS rather than a constant of its own: the two
have to agree or a dot would have no bar to become.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The marker could still be caught fading in on the outgoing page. Paging changes
the selection immediately but the state only once the new month has loaded, so
until then the content key — the state's month — is unchanged and AnimatedContent
updates the page *in place*: the old grid really was told the next month's 1st
was selected, and it shows that date among its trailing days.
A page now marks only the days its own month owns. Selection always lands inside
the month it belongs to, so nothing legitimate is lost, and the phantom goes
without timing the fade against the page transition — a delay would have moved
it rather than removed it, and slowed selecting a day within a page besides.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three things still moved badly while paging.
The selection marker was thrown across the outgoing grid. `selected` was read
from outside the AnimatedContent, so both pages rendered the *incoming*
selection — and the new month's 1st is a day the old grid still shows among its
trailing days, so the outline jumped there (bottom right) before the new page
slid in and put it where it belonged (top left). The selection now travels with
the state, so each page keeps its own and the marker only ever appears where its
month has it.
Moving the selection within a month also jumped, outline blinking out of one
cell and into another. Each cell now fades its own outline, so it reads as one
mark crossing the grid. Snapped under reduced motion.
And the day pane swapped its rows outright, popping one list out and another in.
It now cross-fades, the incoming list rising as it arrives — the same entrance
the rest of the family uses for resolved content — with the skeleton handing
over the same way. Rows within a day also animate their placement, so an event
arriving from a sync nudges its neighbours instead of teleporting them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paging between months, weeks and days looked rough, and the transition was the
reason: a full-width slide with no fade at all. AnimatedContent stacks the two
pages and both were fully opaque, so what you saw was one grid racing across
another and clipping at the viewport edge — the movement was carrying the entire
transition, over the longest distance available.
It is now M3's shared-axis X. The offset only hints the direction — a fifth of
the width — while a cross-fade does the swapping, position springs and opacity
eases, and the size transform no longer clips. All three calendar views share
this transition, so all three settle.
Also lifts the month view's swipe threshold from 6dp to the 24dp the week and
day views already used. Six is inside the distance a tap wanders, so brushing
the grid turned the page — which reads as the animation firing at random rather
than as an over-eager gesture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two things made a swipe between months feel rough, both from the grid and the
pane disagreeing about what was happening.
The grid sized itself to its month, 4 to 6 rows, so every swipe shunted the pane
up or down by a row while the grid content swapped underneath with no transition
at all. It now always reserves six rows, padding a short month with blank ones —
which both holds the pane still and makes the paged style's directional slide
safe to use here, since there is no longer a height change to animate under it.
The pane also flashed "Nothing scheduled" mid-page. Paging moves the selection
to the new month before that month's events have arrived, and the pane was
handed `instancesByDay[selected].orEmpty()` — so a day that simply wasn't loaded
yet was indistinguishable from a day with nothing on it, and got reported as
free. It now takes a nullable list and shows skeleton rows for the gap, saying
"still looking" rather than making a claim about the day.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The selected cell also swapped its fill to primaryContainer, which lightened the
whole cell — a second state stacked on the day rather than a mark on it, and one
that fought today's circle when the two landed together. The outline and the day
number carry it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rule between the compact grid and the day pane earns its place in the
Continuous header, where it closes a section off; here it only draws a line
between two halves the layout already separates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening Continuous stayed slow, and the slowness tracked the number of enabled
calendars rather than the window size — which pointed past the provider query to
the layout behind it.
layoutCalendarWeek opens by filtering the instances it is handed down to the
ones touching its seven days. For a single month's grid that is nothing; for the
scrolling styles it was a scan of every instance in an eleven-month window,
repeated for each of a hundred-odd rows, with a time-zone conversion per check.
The work grew with events × rows, so switching every calendar on multiplied it.
- DayIndex buckets the window's events by the dates they cover, once, and each
row takes the handful on its own seven days. Membership is decided by
coversDay itself rather than re-derived from the timestamps — the all-day and
timed cases have enough edge cases between them (UTC anchoring, exclusive
ends, zero-length events at midnight) that a second implementation would
drift. DayIndexTest pins row-for-row equality with the old full scan, in a
zone east of UTC.
- Only the style on screen is laid out. Adding Dense had quietly doubled the
work, since both layouts were built from every load and one was always thrown
away.
- The paged flow no longer queries under a scrolling style, mirroring the gate
the scrolling flow already had.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening Continuous made the first frame wait for nine months of recurrence
expansion when only one was about to be looked at — a provider Instances query
expands across its whole range, so the window's generosity was paid for up
front, every time.
The window now starts at one month either side of the visible one — about what
the paged style costs — and each completed load reaches two months further in
both directions until it spans eleven. The months a scroll can reach arrive
while the first one is already on screen, and because a widening is triggered by
the previous load landing rather than by a timer, the ladder can never outrun
the provider.
- The paged and split styles no longer run this query at all. The screen
collects the flow whatever the style is set to, so until now every Month view
opening paid for a window it would never draw.
- The reload trigger is derived from the current pad instead of being fixed, and
is held strictly inside it: a trigger at or beyond the pad would re-fire the
moment its own reload landed.
- The scrolling styles get their own skeleton — the layout they are about to
become, at the same measurements, so arriving months replace it in place. It
and the per-month placeholders now breathe, so a slow load reads as work
rather than as an empty grid. Held still under reduced motion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continuous's month blocks make the boundaries legible, but the seams are exactly
what someone who wants a dense calendar doesn't want. Both are now offered:
- **Dense** is the old flowing layout, kept rather than replaced — one
uninterrupted stream of weeks with months running into each other, the 1st
naming its month, and the top bar keeping the full month title (there is no
sticky header to defer to).
- Continuous gains a **rule under its month label**, closing the header off
against the grid. On trial: it comes out again if it doesn't earn its place.
One provider query serves both. `ContinuousMonthUiState.Success` now carries the
same loaded window laid out twice — `monthsByIndex` clipped into blocks,
`weeksByIndex` left whole — rather than standing up a second flow and querying
the same range again. Both styles report *months* as they scroll, so the window
hysteresis is shared; `weekWindowFor` maps a month window onto the Dense rows it
covers, widened a week each side so boundary rows don't flicker.
The list state is keyed on style *and* week start: Dense indexes by week, which
the week-start preference moves, while Continuous indexes by month, which it
doesn't — carrying an offset between the two would land somewhere arbitrary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The continuous grid failed outright with "Could not read the calendar" the
moment it opened.
Before the first layout pass LazyListState reports no visible items, and the
screen defaulted that to item 0 — claiming January 1900 was on screen. The load
window widened around it, its pad ran past the epoch to index -4, and
yearMonthForIndex indexed Month.entries out of bounds; the flow's catch turned
the crash into the generic provider failure. Week indexing never showed this:
index 0 was a real week, so a bogus report only cost one wasted query.
- The visible-range report is skipped entirely while the list has no items,
rather than standing in a default for them.
- clampMonthWindow holds any window inside 1900–2100, so a pad at either end
can't produce an index with no month behind it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The continuous style streamed weeks with no boundaries at all, which left it
hard to tell where one month ended: the only marker was a "Jul 1" label on the
1st, and every boundary week mixed two months' days into one row.
It is now a vertical stack of self-contained month blocks. Each block shows only
its own days — the boundary week keeps its seven columns so nothing shifts
sideways, but the neighbour month's cells are blank rather than filled with
duplicates of days shown again a block later — under a sticky month header with
whitespace either side. Scrolling stays continuous; only the reading changes.
- The coordinate space moves from absolute week index to absolute month index
(two LazyColumn items per month: header, then block). Unlike week indices, it
doesn't depend on the week-start preference, so changing that reflows the rows
inside a block without moving the block or losing the scroll position.
- The sliding data window now loads months rather than weeks, widened to whole
grid weeks at both ends so a bar reaching into a block from a clipped-off day
still renders.
- `clipWeekToMonth` is the pure seam: it drops the neighbour month's pills and
counts and cuts spanning bars back to the month's own columns, keeping a flat
cap on the cut side so a bar reads as continuing past the block.
- The top bar carries the year in this style — the block's own header names the
month, so repeating it two lines up was pure duplication.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first version of this picker invented its own visual language — free-
standing bordered cards with a hand-rolled check — which matched nothing else
in the app. It is now the family's standard shape: FullScreenPicker with
PickerDescription and connected GroupedRows, tonal highlight and the shared
SelectedCheck.
Above the rows sits a live, scaled-down Month view that changes as you pick a
style. It renders the *real* grid composables rather than a drawing of them, so
the preview cannot drift from what it depicts: MonthGrid, ContinuousMonthGrid,
SplitMonthGrid and SplitDayPane are now internal rather than private, and the
sample month runs through the same layoutMonthWeeks/layoutCalendarWeek the live
views use. The month, today's position, week start, colour softening and clock
format are all real; only the events are stand-ins, since a settings screen has
no business querying the provider for a thumbnail.
Selecting applies immediately and leaves the picker open — closing on tap would
hide the very thing the screen is for. Back exits, as in the App name picker.
Scaling note worth keeping: Modifier.requiredSize looks like the way to force a
full-viewport measurement, but it *centres* content that overflows the incoming
constraints, which left only the grid's bottom-right corner inside the clip. A
layout modifier that measures at Constraints.fixed and reports the scaled size
has no overflow to align and no dependence on parent alignment.
PickerDescription is internal now rather than duplicated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Filed under Unreleased, matching where the agenda widget fix landed after the
2.16.0 section was cut. Fold into 2.16.0 if that section reopens before it
merges to main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds MonthViewStyle.Split: the month compressed to day numbers and event dots
over a list of the selected day's events.
- The app's first selected-day concept. Every other view drills straight into a
date; here a tap selects, because the pane below is already the answer to
"what's on this day" and opening a whole screen would defeat the layout. The
full Day view stays one tap away on the pane's date header, matching the Week
and Agenda headers (#37).
- The pane reuses the agenda's row vocabulary (extracted in the groundwork
commit) rather than growing a parallel set, so the two surfaces read as one
app. It needs no extra provider query: instancesByDay is already covered by
the month grid range.
- Selection follows the month — today when the new month holds it, else the 1st
— so the pane never lists a day the grid isn't showing. Tapping a leading or
trailing day follows it to its own month.
- Selection and today are different signals (tinted, outlined cell vs. the
filled circle the other views use for today), so both read when they land on
the same day.
- The swipe stays on the grid alone; the pane scrolls and is full of tappable
rows.
MonthScreen gains onEventClick, wired in CalendarHost alongside the other three
views, and MonthUiState.Success now carries its zone for the same reason the
agenda's does.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds MonthViewStyle.Continuous: one uninterrupted vertical stream of weeks
instead of month pages.
The layout unit is the ISO week, not the month. A vertical stack of month grids
would still repeat a boundary week at the end of one month and the start of the
next, which is precisely the duplication the issue asks to be rid of. Streaming
weeks means every date appears exactly once.
- Weeks are addressed by an absolute index (epoch 1900, so indices stay
non-negative and map 1:1 onto LazyColumn item indices), giving the list one
stable, gap-free coordinate space to scroll and key by.
- The view model loads a sliding window of weeks around the visible range.
nextLoadWindow() holds the hysteresis: the window only moves once the visible
range comes within four weeks of a loaded edge, so scrolling re-queries the
provider occasionally rather than every frame. Unloaded rows render a
same-height skeleton, so nothing jumps when the window catches up.
- No dimmed "other month" days — every day in the stream belongs to a month
equally. Instead the 1st names its month, which is the only marker needed to
tell one month from the next, and the top bar title tracks the month the
viewport mostly sits in.
- The horizontal swipe detector and the paged AnimatedContent are both off in
this style; vertical scrolling owns the gesture. Today and drawer jump-to-date
animate the list instead of swapping months. The view opens positioned on the
current month.
MonthWeekRow now takes inMonth as a predicate rather than a YearMonth, which is
what lets the same row serve a stream that has no enclosing month.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces MonthViewStyle{Paged, Continuous, Split} and the setting that
chooses it. Only Paged is wired up so far — the other two land next; this
commit is the pref, the state plumbing and the chooser.
One picker rather than two switches ("vertical scrolling" + "month with
agenda"): independent toggles would multiply into four combinations, most of
which nobody asked for. Split does not disable the Agenda view — that stays a
forward multi-day window, while the split pane lists one selected day.
The chooser lives on the Views settings screen (per-view layout belongs with
the other view configuration) and is a hand-rolled FullScreenPicker rather than
OptionPicker, which cannot render previews: the three options differ in shape,
which a word like "Continuous" does not convey. Each card carries a schematic
drawn from theme tokens, and selection reads three ways over — border weight,
container tint and a check — so it never rests on colour alone. The cards are a
selectableGroup with Role.RadioButton for screen readers.
Folded into the ViewCustomization holder, which had spare arity; the outer
settings combine is still at its five-flow limit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Groundwork for the month view style setting (#38, #53); no behaviour change.
- Split layoutCalendarWeek out of layoutMonthWeeks so the continuous style,
which streams weeks and has no enclosing YearMonth to slice by, lays rows
out identically. layoutMonthWeeks (also used by the month widget) keeps its
signature and becomes a loop over it.
- Add MonthUiState.Success.instancesByDay: the grid's events keyed by date and
uncapped, so the split style's day pane can list a date without a second
provider query — the month grid range already covers it.
- Move AgendaDayHeader / AgendaEmptyDayRow / AgendaEventRow and their label
helpers into AgendaRows.kt as internal, so the split pane reuses the agenda's
row vocabulary instead of growing a parallel one. AgendaEmptyDayRow takes its
text as a parameter now that it serves more than "nothing left today".
- Add the month package's first JVM tests, covering week counts, span
continuation across row boundaries, lane stacking and instancesByDay.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
floret-kit!2 (public SelectedCheck) is merged, so the submodule no longer
needs to track the feature branch. Same tree, so nothing rebuilds — this
only stops Calendula pinning a branch that is now deletable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three inconsistencies the recurrence-picker review surfaced, all of the
same kind: the redesigned picker had quietly diverged from the family.
SelectedCheck existed as five copies app-side plus a private one in the
kit, and they had drifted — the kit drew Icons.Rounded.Check, every app
copy drew Icons.Default.Check. Delete all five against the kit's now
public primitive (floret-kit!2), which settles the glyph on Rounded.
The recurrence picker was the only full-screen picker whose selected row
carried no check, relying on the tonal highlight alone. Add it to all six
rows, matching OptionPicker, reminder, agenda range, timezone, calendar
and Settings.
Its two new AnimatedVisibility blocks were the only ones in the app using
Compose's bare defaults rather than expandEnter()/collapseExit(). Those
helpers honour rememberReduceMotion(), so the weekday and count cards
were ignoring the system "remove animations" setting — an accessibility
regression, not just a style drift.
Bumps the floret-kit pointer; needs floret-kit!2 merged first.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups to the picker redesign, plus the bugs it exposed.
The live read-out now renders customResult itself — the exact string OK
would save — instead of rebuilding a parallel rule with `interval ?: 1`
and `end ?: Never` fallbacks, which let it confidently describe a rule
that differed from the selected controls whenever a field was invalid.
Shrink the invalid space behind it: a blank amount field reads as its
visible placeholder (1 / 10) rather than as an error, and backing out of
the date picker falls back to "never" instead of stranding a dateless
"on a date". Only an out-of-range 0 remains invalid, and that now says so
rather than greying out OK with no cause.
UNTIL displayed the day after the one picked for zones behind UTC:
toRRule deliberately writes the end of the chosen *local* day expressed
in UTC (the provider applies UNTIL coarsely), so the read side must
convert back before taking the date. Fixes the detail screen too, and
untilLocalDate is extracted so it can be tested.
Also: hoist a remember() out of a conditional (a slot that appears and
disappears breaks positional memoisation), match GroupedSurface's 22dp
corners instead of a drifted local 20dp copy, move the cards onto
GroupedSurface, drop the segmented row's icon slot so longer unit labels
fit, and reserve two lines so the stack stops shifting as the phrase
grows with each weekday.
Extract SettingsPrefs.firstDayOfWeek(scope), replacing three copies that
had drifted onto different initialValues.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to 30fcbfa, fixing eight issues found in review:
- Cap the agenda row list at 100. SizeMode.Exact asks Glance for one
RemoteViews per host size where SizeMode.Single produced exactly one,
roughly doubling the payload; with the range reaching
AgendaRange.MAX_CUSTOM_DAYS (365) an uncapped list could push past the
binder transaction limit and the host would show "Problem loading
widget". A trailing day header stranded by the cut is dropped.
- Loosen the height cap so it only catches genuinely squashed widgets.
It previously required ~320dp of height for LARGE, so widening a widget
without also making it unusually tall — the exact resize #51 reports —
stayed REGULAR or COMPACT and the feature was near a no-op for it.
Thresholds now work on height minus header chrome.
- Lock the event stripe to the system font scale. It is a Dp beside sp
text, so at large accessibility settings the text outgrew it and it
under-ran the row it marks.
- Route the day-header and placeholder padding through the metrics table
so vertical rhythm holds at the larger tiers, and derive the text
indent from the row constants instead of a hardcoded 19dp.
- Share the bucketing as widget/WidgetScale.kt so MonthWidget (already
SizeMode.Exact) can adopt one rule rather than growing a parallel copy.
- Anchor the type ramp to Material 3 type-scale roles per CLAUDE.md, with
the two off-scale values marked and justified inline. COMPACT is
unchanged, so a default-sized widget still looks exactly as before.
- Tie the "default size unchanged" test to the provider XML's declared
3-cell band rather than one measured 222dp point.
- Hang the metrics off an ordinal-indexed table so lookup allocates
nothing per recomposition.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correctness:
- Recurring writes: move the series DTSTART by the *wall-clock* shift applied
to the edited occurrence and re-resolve it in the event's zone, instead of by
a millisecond delta. The old delta baked in whichever UTC offset applied on
the edited occurrence's date, so pinning a recurring event to another zone —
or editing an occurrence on the far side of a DST boundary from the series
anchor — shifted the whole series by an hour. Also snaps the anchor to a UTC
midnight when the event becomes all-day.
- Detail card and edit form now resolve a pinned zone's abbreviation/offset at
the *event's* instant, not at "now", so a July event no longer reads
"CET · 10:00 AM" when opened in January.
- Agenda: the zone used to label multi-day rows now travels on
AgendaUiState.Success rather than a process-lifetime file-level constant, so
labelling can't disagree with the grouping after a device time-zone change.
- Week title: spell out the year when the week straddles New Year, via a new
forceYear flag on formatCalendarTitle.
Performance:
- Build the ~600-entry zone catalogue off the main thread (produceState +
Dispatchers.Default); resolve the device row's summary on its own so it still
renders complete on the first frame.
- Pre-normalize each TimeZoneOption's search keys at construction, turning
~2400 NFD normalizations per keystroke into plain prefix/substring checks.
Hoist the combining-mark Regex out of the hot path.
- Key the edit form's local-time line on the fields it reads instead of
recomputing it on every keystroke.
- Move LauncherNameManager's PackageManager binder calls off the main thread.
Cleanup:
- Drop a duplicate Public icon import and the unused
event_edit_timezone_clear string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The weekday toggles now order by the app's "Week starts on" setting
(via WeekStartPref.resolveFirstDay), matching the month/week/agenda
views, instead of always following the device locale. Threaded through
a new EventEditViewModel.firstDayOfWeek flow.
- Carry the 16dp section gaps on each block rather than a parent
spacedBy, so the weekday card's gap collapses together with the card
under AnimatedVisibility — removing the end-of-animation jump in the
ends section when switching away from Weekly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Upcoming" agenda widget used Glance's default SizeMode.Single: it was
composed once at the minimum size and the launcher stretched that single
RemoteViews when enlarged, so the text stayed small-widget-sized no matter how
big the widget grew. Reported as a "font size" request (#51), but it's really a
missing size-response.
Switch to SizeMode.Exact (like MonthWidget) and read LocalSize.current to pick
one of four tiers (COMPACT/REGULAR/LARGE/XLARGE), scaling type and row metrics.
Exact over Responsive so the ~30-day LazyColumn isn't replicated per tier.
Width picks the tier, height can only lower it. Width governs how much of a
title fits on a row, so it's what should drive type size; height only decides
how many rows are visible, so a tall narrow widget shows more events rather than
bigger text. Height does act as a cap, though, or a squashed widget would keep
the large type its width earned in a sliver of space. Thresholds are spread over
the width range a phone actually produces (measured on a Pixel/Nova: a compact
widget is 222dp wide, a large one 378dp) rather than a theoretical range, so the
tiers are reachable in practice; XLARGE is reserved for tablets/foldables.
COMPACT reproduces the original constants verbatim, so an existing widget is
visually unchanged. The tier logic lives in a pure, Glance-free AgendaScale.kt
(compose.ui.unit only) and is JVM-tested: the COMPACT baseline, the width
buckets, the height cap stepping a squashed widget down, and that height never
raises the tier.
No new setting: the widget follows the size the launcher/user already chose.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Codeberg publish step (added but not yet exercised by a release)
would 500 the same way agendula's did: the tag is created via the Gitea
API, which the push mirror doesn't propagate promptly, so a release POST
with target_commitish outruns the mirror and fails on an unknown commit.
Push the tag straight to Codeberg from the runner, then create the
release against that existing tag with no target_commitish. Race-free.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the single current-state hero + option rows with two selectable
launcher-mark cards (Calendula / Calendar), so the user sees what each choice
would look like, not just the current name. Tapping a card applies immediately
and highlights it (primary border + tinted container + check); the picker stays
open so the change is visible, and back exits — which also fixes the earlier
"hero doesn't update until reopened" gap.
Built on FullScreenPicker directly (the component OptionPicker wraps) to render
the custom card row; material-3 tokens throughout (surfaceContainerHigh /
primary / primaryContainer / outlineVariant, 24dp corners, 8dp-grid spacing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a preview hero to the App name picker header: the app's launcher mark over
the current name on a tonal surface card, the explanatory hint below it, then
generous space before the choices. Gives the picker the visual "display" the
inline hint lacked and separates the text from the options.
Follows the material-3 guidance — tonal surfaceContainerHigh card (no shadow),
28dp corner, 8dp-grid spacing, onSurface/onSurfaceVariant role pairing — and
reuses the onboarding BrandHero's squircle reconstruction of the adaptive icon
so it renders identically everywhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the inline App name switch with a GroupedRow that opens a full-screen
OptionPicker (Calendula / Calendar), matching the app's other "choose one"
settings and the ReFra pattern the user preferred. The row shows the current
name; the picker's header carries the explanatory + icon-may-move hint. Leaves
room for more launcher names later without redesigning the row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuild the custom step of the recurrence picker so complex-but-supported
rules (e.g. "every 2 weeks on Mon+Tue") are discoverable and legible,
rather than buried in a ragged stack of mismatched controls.
- Add a live, human-readable summary of the rule as it is built, via the
existing recurrenceText humanizer.
- Group interval + frequency into one tonal card; the frequency is now a
SingleChoiceSegmentedButtonRow (all four units visible) instead of a
dropdown.
- Reveal the weekday picks with AnimatedVisibility and house them, the
"every" controls, and the ends group in consistent 16dp-inset grouped
cards; the count field folds into the bottom of the ends run.
No behaviour, domain, or RRULE changes: parse/render logic and the set of
expressible rules are unchanged. The EXDATE half of #42 is deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Settings → Appearance toggle that switches the app's launcher label
between "Calendula" and "Calendar", for users on launchers that can't rename
apps themselves.
The launcher entry moves off MainActivity onto two <activity-alias> components
(DefaultNameAlias / CalendarNameAlias); exactly one is enabled at a time via
PackageManager.setComponentEnabledSetting. MainActivity keeps every other intent
filter; the android.app.shortcuts meta-data moves onto both aliases so the
long-press shortcut still publishes. Component-enabled state is the single source
of truth — no persisted preference.
The ComponentName uses the applicationId for the package (carrying the
.debug/.releasetest suffix) and the namespace for the class, since manifest
".Alias" names resolve against the namespace; switching to Calendar enables the
target alias before disabling the other to avoid a zero-entry launcher transient.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agenda window starts at the anchor's local midnight, which east of UTC
is the previous day's 22:00 UTC. All-day events live at UTC midnights with an
exclusive next-midnight end, so yesterday's all-day event (a birthday, say)
still overlaps today's window start and is returned by the provider.
groupAgendaDays then clamped its first day up to the anchor and, via a trailing
coerceAtLeast(firstDay) on the last day, pulled it onto the anchor's "today"
section — the multi-day fix only stopped the forward leak on interior days.
Drop instances whose true last day (resolved in UTC for all-day events) falls
before the anchor, or whose first day falls past the window end: they occupy no
visible day and must not be clamped onto an edge. Adds eastern-zone regression
tests for both edges.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the today icon-button block that was copy-pasted into all four
calendar top bars (Week/Month/Day/Agenda) into one TodayAction composable in
ui/common. No behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Today button in toolbar" appearance toggle (default off). When on,
each calendar view (month/week/day/agenda) shows a persistent go-to-today
icon button in its top bar instead of the fade-in extended FAB, and the
FAB pill is suppressed. Same jump action, just always present.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code-review follow-up.
- The multi-day expansion in groupAgendaDays is shared with the agenda
widget, but only the screen's summary was made day-aware — so the
widget rendered the raw "start – end" on every spanned day, the very
bug the screen fix cured. Hoist a pure agendaTimeLabel(event, day,
zone) into the shared agenda layer and resolve strings from it in both
the screen and the widget, so they label identically. (findings 1, 2)
- groupAgendaDays could silently drop an instance whose clamped span was
empty (firstDay > lastDay); floor lastDay at firstDay so a returned
instance always surfaces on at least its first visible day. (finding 3)
- agendaTimeLabel resolves the span days once instead of the summary
recomputing them 2–3× per row. (finding 5)
Finding 4 (within-day sort) needs no change: sorting by absolute start
already places a still-running multi-day event at the top of each day it
continues into, which is chronologically correct (it is ongoing from
that day's midnight), and at its real start time on its first day.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-up. spanFirstDay/spanLastDay resolved every event in the
device zone, but all-day events live at UTC midnights with an exclusive
end — east of UTC (e.g. Europe/Berlin) that pushed spanLastDay onto the
next day, so a single-day all-day event reported spansMultipleDays and
leaked onto a second agenda day. Resolve all-day dates in UTC, matching
the Week view and detail card. Adds eastern-zone regression tests that
the prior UTC-only tests could not catch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "→" arrow convention read as unclear. Spell each day out instead:
the first day names the start ("Starts 14:00"), the last day the end
("Ends 10:00"), and whole days in between read as "All day". All-day
multi-day events stay "All day" on every day. Single-day rows unchanged.
Adds agenda_span_starts / agenda_span_ends (owes Weblate backfill).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A multi-day event repeated the same "start – end" on every day it spans
("14:00 – 14:00"), which reads as meaningless. Show only the part
relevant to each day, with a "→" marking that it carries past the day's
boundary: the first day shows the start ("14:00 →"), the last day the
end ("→ 10:00"), and whole days in between an all-day span arriving from
and continuing into their neighbours ("→ All day →"). Single-day rows are
unchanged.
Factors the span first/last-day resolution into shared EventInstance
helpers reused by groupAgendaDays and the label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A multi-day event now appears under every day it spans, so keying its
row by instanceId alone repeated the key across days and crashed the
LazyColumn ("Key already used") on scroll. Scope the key by day.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Search matched city, id, and the long localized name but not the
abbreviation, so typing "CEST" found nothing. Match it too: an exact
abbreviation hit ranks just under a city prefix, so typing an abbreviation
gathers every zone that shows it (all the CEST zones at once). It matches
the region-resolved abbreviation — i.e. exactly what the row displays.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CalendarRepositoryImpl gained a SettingsPrefs parameter, but this
instrumented smoke test still called the three-arg constructor — so the
whole androidTest source set failed to compile. Pass a SettingsPrefs
built on the same DataStore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
groupAgendaDays keyed each instance by its start day alone, so a
multi-day event surfaced only on its first day and vanished from the
rest of its span in both the Agenda screen and the agenda widget.
Expand each instance across every day from its start (clamped to the
anchor for ongoing events) through its last occupied day, bounded by the
visible window end. An event ending exactly at midnight — including the
exclusive next-midnight all-day events end at — does not reach that
boundary day.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
java.util gave abbreviations only for zones in the display locale's region:
an en-DE phone saw "CEST" for Berlin but "GMT-4" for New York, and an
en-US phone the reverse — ICU only surfaces the short names commonly used
where the reader is. Verified on-device (en-DE): US zones fell back to the
offset, EU ones resolved.
Ask ICU in the display language but the *zone's* region instead — New York
in en-US, Berlin in en-DE — using android.icu's zone→region map. On-device
that lights up EDT/PDT/CDT, plus BST, AEST, IST, JST that showed only the
offset before. Where a region still has no name (Athens in en-GR) the
device locale sometimes does, so fall back to it, then to the offset.
The region lookup needs android.icu, which domain/ can't import, so it's
injected: timeZoneOptions takes a regionOf lambda (default none, keeping
the module pure and JVM-tested), and the UI passes icuTimeZoneRegion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The abbreviation came from java.time's "zzz" formatter, which on Android
has no short specific-zone names and silently degrades every zone to a
"GMT-4" form — so on device New York showed "GMT-04:00" instead of "EDT".
Desktop couldn't catch it: there "zzz" and java.util.TimeZone agree, and
that agreement is the whole trap.
Resolve through java.util.TimeZone.getDisplayName(inDst, SHORT, locale)
instead — ICU-backed, so it returns the real abbreviation on both Android
and the JVM. Zones with no named abbreviation still fall back to a GMT
form, which zoneDescriptor already collapses to a single token.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Publish the signed release APK and a SHA-256 checksum as a Codeberg
release, a direct-download channel for users who don't want F-Droid.
Gitea already push-mirrors branches and tags to Codeberg, but releases
aren't git objects and don't sync, so the pipeline creates the release
over the Codeberg API and attaches calendula_v<version>.apk + .sha256.
Same app-signed APK the F-Droid repo serves, so no added trust surface.
Best-effort (continue-on-error) so a Codeberg outage never fails an
already-published F-Droid release; skips cleanly when CODEBERG_TOKEN is
unset. Upsert + same-name asset replacement keep re-runs safe.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Central European Time (Europe/Berlin)" is too wide — it made the zone
field wrap and stretch. Lead with the id ("Europe/Berlin") and follow it
with the abbreviation instead: "CEST · GMT+02:00" in the picker and edit
card, "CET · 8:00 AM – 9:00 AM" on the detail card (abbreviation + the
event's own-zone time).
The abbreviation is resolved DST-aware at the same instant as the offset
(CET vs CEST) via "zzz"; zones with no named abbreviation fall back to a
"GMT+05:30" form, and zoneDescriptor drops the separate offset in that
case so it isn't stated twice. The long localized name is kept on the
option for search only — typing "pacific" still works — but no longer
shown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The When card reads date-large, time-small-beneath. The zone card led
with the original time at titleMedium and dropped the zone label to
bodyMedium, inverting that — which made the foreign time the loudest
thing on the screen and pulled attention off the local time the reader
actually acts on.
Put the label back on top and the original time small beneath it, so both
cards read the same way and the original stays available without
competing. The label already names the zone, so the range drops its "in
New York" tail (and the string with it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review fixes.
The zone picker's search box was a raw Material OutlinedTextField — the
only one left in the app, and against the convention DialogControls
states outright ("the family's InlineTextField over a tonal surface, not
Material's outlined field"). Rebuild it on InlineTextField over a tonal
surface, with the clear button inside the surface since the picker's top
bar is the title rather than a search field.
Showing a pinned event only in its own zone answered "what was it set
to?" while dropping "when is it for me?" — the user had to do the offset
arithmetic. Show both whenever they differ:
- the edit form keeps editing the event in its own zone (that's the time
it was set at) and captions it with the local equivalent;
- the detail screen keeps local times primary and now leads the zone card
with the original ("8:00 AM – 9:00 AM in New York") instead of naming
the zone and nothing else.
EventForm.timesIn is pure, so the conversion — including crossing the
date line and each zone's own DST, which don't move together — is a
plain JUnit test rather than something only reviewable on a phone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The time-zone picker needs the scaffold's `scrollable` opt-out, which
landed in 0.2.1. Pins the tag rather than the branch commit the work was
developed against, keeping the tag-pinning convention.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface the zone as an optional form field, right after the time fields
it qualifies: hidden on ordinary events, revealed automatically when the
event already carries a foreign zone, and withheld entirely while all-day
is on (a date-anchored event has no zone to show).
The picker is a full-screen one per the app's convention, but it can't
reuse OptionPicker: that composes every option eagerly, and ~600 zones
would all compose on open. It drives its own LazyColumn instead, which
needs the kit's new `scrollable = false` — the scaffold's own
verticalScroll would otherwise throw on a nested same-axis scrollable.
The device zone and recently-picked zones pin to the top so the common
case needs no typing; search is accent- and case-insensitive.
Recents persist in DataStore, capped at five, dropping ids the tz
database no longer knows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every write sampled ZoneId.systemDefault() and stamped it into
EVENT_TIMEZONE, so the column was real but only ever held the device's
zone: an event synced from elsewhere could be read in its zone, never
authored in one.
Give EventForm a nullable `timezone`, where null keeps meaning "the
device zone at save time" — so every existing call site behaves exactly
as before — and a non-null value pins the event to a zone it then tracks
across DST. toWriteTimes resolves the form's zone ahead of the device's;
toEditForm pins only when the stored zone differs from the device's, and
prefills such an event in its own zone so the form shows the wall-clock
the event actually means.
Two provider-contract bugs fall out of this:
- Editing the time of a foreign-zone event rewrote EVENT_TIMEZONE to the
device's. The instants stayed right, so nothing looked wrong, but the
event silently stopped tracking its zone and would drift an hour at the
next DST boundary. Only the timesChanged gate spared title-only edits.
- A zone change with an untouched wall-clock is still a time change (the
same 09:00 elsewhere is a different instant), so it now trips
timesChanged and rewrites DTSTART instead of being dropped.
All-day events keep carrying no zone at all: they're date-anchored, and
the UTC midnights they normalise to are an anchor rather than a location.
TimeZoneCatalog is pure JVM so the search ranking and DST-aware offsets
stay plain JUnit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tracks the tagged kit main rather than the feature branch the merge left it on.
A release branch pointing at an unmerged branch is the failure mode main carried
since 2.15.0 (its pointer lived only on feat/custom-snooze-duration): the release
pipeline builds from the tagged source tree, so a rebased or deleted branch would
break the published release, not just a local checkout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Locale-aware calendar titles (#60): Month, Week and Day move onto the shared
formatter Agenda already used, the year drops out while you're in the current
one, and the Week title names its month rather than restating the day numbers
printed directly below it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Month, Week and Day each hand-rolled their title from a German-style template —
"$weekday, ${date.day}. $monthName ${date.year}" — so every language got a
trailing ordinal dot and day-before-month order, including ones that write the
month first. English read "Fri, 17. Jul 2026" where it should read
"Fri, Jul 17, 2026".
396a561 already fixed exactly this, but only for Agenda: it added
localizedDateFormatter and migrated the four agenda files, leaving Month, Week,
Day and MonthWidget on their originals. Day and Agenda therefore disagreed about
the same date. All four now route through formatCalendarTitle, so there is one
place left that decides how a title reads.
Two behaviour changes fall out of the reporter's point that the bar wastes space:
The year is dropped while you are in the current year. The title sits above a
grid that already says which year it is; the year's absence is itself the signal
that you are in the current one, and it reappears when you page out — the moment
it starts carrying information. Since a skeleton is a field list, this is just
appending "y", and the locale still places it.
The Week title names a month instead of a day range. "24. Jun – 31. Jun" restated
the day numbers printed in the column headers directly below, in the widest
string in the bar. Naming weekStart's month keeps a straddling week on the
outgoing month until it is fully gone — a week is seven contiguous days, so the
earlier month has a day in it exactly while weekStart is inside it. No straddle
conditional, and the title depends on nothing but weekStart, so it cannot drift
with the direction you paged in from.
currentLocale/localizedDateFormatter move to floret-kit's core-locale (neither is
calendar-specific); LocaleSupport.kt goes away and its 11 callers repoint.
MonthWidget keeps Locale.getDefault() — Glance has no LocalConfiguration and the
widget re-renders on a configuration change, matching AgendaWidget.
Does not touch the FAB stack the issue opens with: the buttons overlaying content
is intentional and matches Google Calendar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the Unreleased changelog under [2.15.0] — 2026-07-15, bump
versionName/versionCode to 2.15.0/21500, and sync the F-Droid
per-version changelog.
Milestone 2.15.0 (all integrated): #21#35#36#39#40#46#52.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a "Soften calendar colours" setting (Settings → Design, default on).
Turning it off paints calendar and event colours raw, exactly as the sync
source (DAVx5/CalDAV) publishes them, instead of the theme-fitting pastels
(#36).
Event/calendar colours now flow through shared eventFill()/eventInk()
helpers gated by a LocalSoftenColors composition local (widgets read the
pref directly). Event titles pick black or white text by the fill's WCAG
relative luminance, so a dark colour stays legible whether softened or raw
(#21) — previously always near-black.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both landed as community translations through Weblate (French ~33%, Polish
~57%), above the bar already shipped for zh-CN. Add them to locales_config.xml —
the single source of truth for the in-app language picker and Android's per-app
language settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Also correct the #35 entry — the empty-today marker is a card in the app but a
plain line in the widget, so call it a "note" rather than a card.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The day view's 48dp edge-to-edge hour gutter centred its labels at 24dp, 4dp
left of the hamburger. Give the gutter content the same 8dp start inset as the
week view so the labels centre on the hamburger (4dp bar inset + 24dp half icon).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The month grid was inset 4dp while its weekday header (and the loading grid) used
8dp, so the week-number column — and the day cells under their labels — sat 4dp
left of where they should. Bring the grid to 8dp: the gutter centre now lands on
the hamburger (4dp bar inset + 24dp half icon), and day cells sit under their
weekday labels.
The week view's header badge and hour labels had the same drift (a 48dp edge-to-
edge gutter centres its content at 24dp, not 28dp). Its top section background
bleeds full-width when scrolled, so instead of insetting the whole content, give
just the gutter content an 8dp start inset to centre it on the hamburger too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The agenda widget's event rows are plain (a colour stripe + text, no card), so
the rounded "No more events today" surface looked out of place. Render it as a
muted line indented to the event titles instead, matching the widget's style.
The in-app agenda keeps its coffee-cup card, where event rows are cards too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Duplicate action to the event detail top bar that opens the shared
event form seeded with a copy of the event as a new, unsaved event, so a
non-recurring event can be re-created with just the day and time changed
instead of re-entering every field.
The copy reuses the existing prefilled-create overlay (createEvent), so it
becomes an independent event with the default reminder applied. Recurrence
is dropped — a duplicate is a single event; the edit form still exposes a
recurrence picker for anyone who wants a series. The occurrence's own times
carry over unchanged. Duplicate is offered for any loaded event, including
read-only ones (WebCal, birthdays): the source calendar is kept only when
it's writable, otherwise the copy resolves to the first writable calendar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Localize every agenda date via a new localizedDateFormatter helper that lays a
field skeleton out in the locale's own order (Android best-pattern), instead of
a hardcoded day-month-year layout: the range-window summary, the screen's day
headers, and the widget's day headers. This also fixes the window mixing two
orders (e.g. "15 Jul – Aug 13, 2026").
Refine the range bar: the banner drops the range name (it already sits on the
selector button beside it) and shows just the concrete dates; the selector keeps
a subtle neutral surface tint — distinct from the top-bar view switcher's
secondary container so the two don't compete — and its right edge lines up with
the switcher. The anchored empty-today card takes a single event row's resting
corner radius.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an "Always show today" setting (Settings → Agenda, on by default) that
keeps today as the first entry in both the Agenda screen and its home-screen
widget even once nothing is left today. Under today's normal header a small
"No more events today" card appears — the coffee-cup empty-state motif in the
app, a rounded surface in the widget — so the first rows you see are clearly
today's rather than a future day's.
The anchor is a pure, JVM-tested helper (anchorTodayIfMissing) applied after
past-event filtering; in-app it only kicks in when the window starts on today,
never on a jumped-to date. The widget reads the pref reactively via per-instance
Glance state, mirroring the range/past-event settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round out the calendar-intent surface toward AOSP/Etar parity — the app
already handled VIEW (date + event), INSERT, and .ics open/share, but was
missing the edit action and the alternate .ics MIME labels.
- ACTION_EDIT on content://com.android.calendar/events/<id> now opens the
event in the edit form (previously only VIEW → read-only detail existed).
An assistant, task app, or widget can hand an event to Calendula to edit.
A bare EDIT URI with no occurrence extras falls back to the event row's
own DTSTART/DTEND, mirroring the #48 view-event fallback.
- ACTION_EDIT with no event id (AOSP's "edit a new event") maps to the same
prefilled create form as ACTION_INSERT.
- The .ics VIEW/SEND filters now also accept text/x-vcalendar (vCalendar
1.0 / .vcs) and application/ics — the alternate labels the same calendar
data arrives under from some file/mail apps (matches Etar's ImportActivity).
Deliberately excluded: webcal:// / http(s) remote-calendar subscription
(needs INTERNET, which the app doesn't have) and the Google-web-link handler
(Google-specific + network).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A reminder fired ahead of an event on a different day showed only the
event's time (e.g. "09:30 – 10:00"), making it look like it was
happening today. reminderTimeText now prefixes timed events with a
relative day: "Tomorrow"/"Yesterday", the short weekday for another day
this week, or the exact date for anything further out.
The this-week boundary honours the user's "week starts on" setting: the
resolved first day of week is threaded through from ReminderNotifier, so
e.g. a Sunday reads as next week under a Sunday-start locale. All-day
events keep their explicit date (never ambiguous), and cross-midnight
timed events keep both explicit dates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The custom snooze duration shipped in 3c97673 but had no changelog entry;
add it to the Unreleased section so it's in the 2.15.0 release notes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The release/* branch protection requires the "Translations / check" status,
but the workflow was path-filtered to translation resources. A code-only PR
targeting a release branch never touches those, so the workflow never ran,
never posted its status, and the required check stayed pending forever —
permanently blocking the merge (only PRs that happened to change strings could
satisfy it).
Drop the path filter so it runs on every PR, mirroring the always-on `ci` job.
The parity check is SDK-free and passes when the committed translations are
consistent, so running it on unrelated PRs is effectively free.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The calendar row in the editor is now tappable when editing an existing
event: picking a different calendar moves the event there on save, rather
than forcing a delete-and-recreate.
CALENDAR_ID is sync-adapter-owned and can't be updated in place, so the
move is copy+delete: the master row is re-inserted on the target calendar
(preserving UID_2445 so backup dedup and sync identity survive), its
reminders and editable guests are copied, and — for a recurring series —
every exception is replayed against the new master (modified occurrences
via CONTENT_EXCEPTION_URI, cancellations as STATUS_CANCELED). The user's
field edits are then applied with the normal series update. Everything on
the new side is built before the source is deleted (post-before-delete),
with a rollback of the copy on any failure, so a move is all-or-nothing.
A calendar change forces whole-series scope, so it skips the recurring
scope dialog. Managed special-dates calendars stay locked. Colour is not
carried across (a raw/keyed colour may be invalid on the target account),
matching the existing calendar-switch behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the Unreleased fixes (#47, #48, #49, #22) under a 2.14.1 heading, bump
versionName/versionCode to 2.14.1/21401, and sync the F-Droid per-version
changelog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prompt is raised whenever a default is configured and the file's reminders
differ from it — including when the file carries none at all, not only when it
brings its own. On-device verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cancelled-exception fix works on synced calendars but not local ones. A
cancelled exception only attaches to its parent through ORIGINAL_SYNC_ID; a
local event has no _sync_id, so the link never forms and the provider's
expansion of the *parent* collapses — every other occurrence disappears, which
is the original #47 corruption, just on a different calendar type. Verified
on-device both ways: a DAVx5 series survives a single-occurrence delete, the
same series on a LOCAL calendar vanishes entirely.
deleteOccurrence now branches on _sync_id. Synced events keep the (verified)
exception path. Events without one — local calendars, and synced events not yet
pushed — add the occurrence to the master's EXDATE, which needs no parent link
and is the canonical iCalendar way to drop one; a sync adapter carries it
upstream unchanged if the calendar later syncs.
Two provider quirks shape the write (both observed on a Pixel):
- An EXDATE-only update is not treated as a recurrence change: the expanded
Instances rows are left alone, so the occurrence stays visible. The
time/recurrence set has to ride along to force re-expansion.
- DTSTART alone is worse — the provider then recomputes lastDate as if the event
were a single instance and collapses the series to its first occurrence.
DTSTART + DURATION + RRULE + zone together re-expand it correctly.
This path is reached in normal use: Calendula's own contact special-date
calendars are local and hold all-day yearly series, so deleting one birthday
occurrence went through it. All-day series take the VALUE=DATE EXDATE form.
Adds pure buildOccurrenceExdateValues + JVM tests (timed, append, duplicate
fold, all-day).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A recurring series row carries DURATION, not DTEND, so EventDetailMapper's
end == begin fallback rendered it zero-length. That was harmless while every
caller supplied per-occurrence times from Instances, but the bare
content://…/events/<id> VIEW intent added in #48 names no occurrence and
keeps the row's own times — so a series opened from a third-party widget
without begin/end extras showed as "10:00 – 10:00".
Read DURATION in the detail projection and derive the end from it, the same
way SearchMapper and IcsExportMapper already do.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2.14.1 branch carried four fixes but only the CalDAV colour picker
(#22) had a changelog entry. Adds the recurring single-instance delete
fix (#47), the external VIEW-intent handling (#48) and the default
reminder on ACTION_INSERT / .ics import prompt (#49), plus their issue
link definitions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Split the two prefill paths that share openImported(): an ACTION_INSERT
intent still auto-applies the settings default (it carries no reminder
semantics), but a .ics file — which owns its reminders — no longer silently
decides. It keeps the file's reminders and raises a one-time prompt
("This event was imported with N reminder(s) — apply your default?") so the
user chooses. The prompt is skipped when there's no real choice: no default
configured, or the file already carries exactly it.
openImported() now takes an ImportSource; CalendarHost tags the overlay
Insert vs File. Accepting swaps in the default and reveals the section;
declining (or dismissing) keeps the file's own reminders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
External ACTION_INSERT launches (e.g. Google Maps' "add to calendar",
the Todo Agenda widget) share the single-event .ics prefill channel:
CalendarHost routes requestedInsertForm as importForm, so EventEditScreen
calls openImported(), which froze reminders as touched to respect a file's
own VALARMs. But an insert intent carries no reminders, so the empty freeze
just suppressed the configured settings default — the event opened (and
saved) with no reminder.
Make the freeze follow the source, not the path: a form that carries its
own reminders (an .ics with VALARMs) still freezes them; a form with none
(every insert intent, and an .ics without VALARMs) falls back to the
settings default via applyDefaultReminder(), exactly like openNew(). An
intent that did carry reminders still wins.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CalDAV sync adapters (DAVx5) publish all ~147 CSS3 named colours into
CalendarContract.Colors, so the event-colour picker showed a full screen
of alphabetically-scrambled, partly duplicated swatches.
Curation now runs in the space the picker actually paints — every swatch
is softened through pastelize, which pins lightness and caps saturation,
so the raw palette's lightness axis is invisible on screen. Judging
distinctness there: colours that paint identically collapse to one
(folding aliases, dark/light shades of a hue, and the neutrals together),
oversized palettes drop washed-out neutral-origin tints and thin by CIE76
ΔE in painted Lab, and survivors sort continuously by painted hue with the
wheel cut at its single widest gap. The CSS3 dump lands at ~33 distinct,
rainbow-ordered swatches; small hand-picked palettes (Google's) pass
through untouched. Every surviving swatch keeps its provider colour key so
picks still round-trip through sync.
This revives work stranded on fix/caldav-color-picker (never merged) and
adapts it to the floret-kit extraction of pastelize: the curation's
painted-space transform now lives self-contained in domain/pastelArgb as a
mirror of floret's pastelize shaping, rather than the two sharing one
function.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to #30. v2.14.0 handles ACTION_INSERT (the widget "+"), but
tapping an existing event in a third-party widget (e.g. Todo Agenda) never
offered Calendula, because nothing handled ACTION_VIEW on
content://com.android.calendar/events/<id>.
- Manifest: add a VIEW intent-filter matched by the provider's item MIME
type (vnd.android.cursor.item/event), mirroring AOSP Calendar and the
sibling INSERT dir/event filter. A content: VIEW intent carries the
resolved type, so a path-only filter wouldn't match it.
- MainActivity.viewEventKeyOrNull: parse the events URI into the existing
occurrence detail-key channel (the one reminder taps use). Occurrence
times ride as EXTRA_EVENT_BEGIN_TIME/END_TIME when the launcher supplies
them; a bare URI omits them.
- EventDetailViewModel: a NO_OCCURRENCE_TIME sentinel makes loadDetail keep
the event row's own DTSTART/DTEND for a bare URI instead of overriding to
the epoch (would otherwise render at 1970).
Needs on-device verification (intent-filter matching + the widget's actual
extras).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Delete only this event" on a recurring series wrote a cancelled
exception carrying just ORIGINAL_INSTANCE_TIME + STATUS_CANCELED. Without
DTSTART + DURATION the provider clones the master *with its RRULE intact*
and cancels the whole clone, so every other occurrence vanished, the
target survived as a "cancelled" ghost, and re-deleting toggled the
series back — exactly the reported corruption.
Anchor the exception as a single instance (DTSTART + DURATION + zone +
all-day, read from the series row) so the provider clears the inherited
RRULE and cancels only that occurrence — the same discipline the edit
path already documents (Codeberg #16). Also filter STATUS_CANCELED out of
the instances grid query so the cancelled occurrence disappears instead
of lingering as a tappable ghost (NULL status is kept — a normal event).
Extracts the exception ContentValues into a pure buildOccurrenceCancelValues
helper with JVM tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Snooze-duration setting gains a Custom… option: a new single-select
SnoozeDurationPicker keeps the minute presets and adds a Custom row that
expands an amount field with a Minutes/Hours toggle, so any delay is settable
(not just the fixed presets). Closes the settings half of the snooze request.
The three presets-plus-custom editors (reminder default, agenda range, snooze)
now delegate to floret-kit's new CustomAmountEditor instead of each duplicating
the tonal editor card — the app keeps its domain math, strings and labels.
Re-pin the floret-kit submodule to the branch commit carrying CustomAmountEditor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the submodule pin from a loose main commit to the tagged v0.1.0
release (same tree content), so the from-source F-Droid build tracks a
stable, traceable kit version instead of a rolling commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Recapture all phone screenshots on the Pixel 10 Pro (Android 16) at a
uniform 1280x2856 across every locale, showing the current UI with sample
events: week, month, day, event detail, agenda, and the calendar-access
onboarding. Replaces the old 05-edit shot with 05-agenda (the light theme
has no standalone edit screen) for en-US and de-DE.
Add fully localized store metadata (title, summary, full description) and
per-locale icon for Italian (it-IT) and Spanish (es-ES), matching the
existing en-US/de-DE listings; the app UI is already translated for both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unify the app's "choose one" surfaces on the full-screen picker style
(floret-kit FullScreenPicker/OptionPicker) instead of the OptionCard modal
dialogs, for consistency across the app:
- Event editor: visibility + add-field -> OptionPicker; reminder, recurrence
rule, and colour -> FullScreenPicker, with the custom-value Add / OK and the
colour Reset carried in the app-bar via the picker's new `actions` slot; the
save-conflict chooser -> full-screen.
- The recurring scope choosers stay compact OptionCard popups — saving an edit
to, or deleting, a recurring event — since a quick 2-3 option decision reads
better as a popup than a near-empty full screen.
Bumps the floret-kit pin (55ad536 -> e1919ca) for the FullScreenPicker
`actions` passthrough.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the floret-kit migration onto the 2.14.0 release line: Calendula now
draws its shared UI/crash/locale/reminder/time code from the floret-kit
submodule (Gradle composite build) instead of inline copies, plus the
week-number isoWeekNumber extraction and the wrapped-title height animation.
Pins floret-kit at 55ad536 (origin/main). Integrated for a full pre-release
verification sweep; on-device review still owed before cutting the release.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Long titles wrap to a second line (#33); the field's height — and the
accent bar and cards below it — snapped to the new height. Apply the new
floret-kit Modifier.animateContentSizeMotion() to the title field so the
height eases on the M3 Expressive motion scheme (snapping under reduced
motion), and bump the kit pin (cded442 -> 55ad536) to the commit adding it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Week and Month grid headers each inlined the same ISO-week-number
computation. Replace both with the new shared
core-time LocalDate.isoWeekNumber(), and bump the floret-kit submodule
pin (5a576c4 -> cded442) to the commit that adds it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the 2.14.0 feature set (Month week numbers, .ics restore +
per-calendar export picker, Day view on date-header tap, wrapped long
titles, Simplified Chinese) onto the floret-kit migration without
resurrecting the inline component copies the kit now owns.
Conflict resolutions (both were import-block collisions where the
migration repointed to de.jeanlucmakiola.floret.* while release kept the
old app-local imports):
- CalendarsScreen.kt: keep the kit imports (CollapsingScaffold,
DialogAmountField/DialogUnitDropdown, collapseExit/expandEnter,
predictiveBack) and repoint FullScreenPicker + positionOf to the kit
(both moved out of ui.common). Keep the genuinely app-local
LeadingAvatar/SourceLogo/curatedSourcePackage; drop the renamed
calendarCollapseExit/calendarExpandEnter (0 uses).
- ImportScreen.kt: keep the app-local CalendarPickerGroups, use the kit's
predictiveBack, drop the now-unused OptionCard import.
Semantic fixup: CalendarPickerGroups.kt (new in 2.14.0) relied on
same-package resolution of GroupedRow/Position, which the migration moved
to the kit — added the explicit floret.components imports.
Verified: :app:compileDebugKotlin, ./gradlew test (app + kit), and
scripts/check_translations.py all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supersedes the stale chore/floret-kit-core-time branch: re-applies the
floret-kit migration on top of current main (122 commits ahead of the old
branch's base), pinning the kit at the multi-value-reminders + pinned-picker HEAD.
- Submodule + composite build (includeBuild), 6 module deps, CI submodules:
recursive, reproducible-release scan extended to the kit, F-Droid recipe.
- Deletes the inline copies now owned by the kit (GroupedList, Picker scaffolds,
InlineTextField, OptionCard, DialogControls, CrashReporter + dialog/submit,
OnboardingScaffold, AppLanguage, TimeBridge, ReorderableColumn, DebugRibbon)
and redraws them from components/identity/core-crash/core-locale/core-time.
- Reminder overrides drawn from core-reminders (multi-value ReminderOverride +
codec); Calendula keeps its app-specific bits (all-day resolution, labels,
presets, the multi-select ReminderDefaultPicker, its own CrashReportActivity).
- Theme draws FloretExpressiveTheme while keeping the user-typography param.
Build pending (deferred): run ./gradlew :app:compileDebugKotlin with ANDROID_HOME
(or floret-kit/local.properties) set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add zh-CN to locales_config.xml so the community Simplified Chinese
translation (values-zh-rCN, already committed via Weblate, ~27%) is
selectable in the in-app language picker and Android's per-app-language
settings. Untranslated strings fall back to English.
Fill in the 2.14.0 changelog, which only documented #37: add the three
feature PRs that also landed on this branch — .ics restore + per-calendar
export (#32), Month week numbers (#25), edit-screen title wrapping (#33) —
plus a note for the new Chinese translation, the missing [#N] link refs,
and the re-synced fastlane en-US changelog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The edit-screen title field was single-line, so long titles scrolled off
one line instead of wrapping. Make it multi-line so it wraps and grows
vertically, matching the detail screen and Google Calendar.
A title is still one logical line: strip any newline the IME's Enter key
or a paste would introduce in setTitle, so no line break reaches the
provider's TITLE column.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per on-device review, render the week number as a full-height tonal pill
mirroring the day cells' geometry (secondaryContainer tint, same rounded
shape and gap), with the number centred — so the gutter reads as part of the
grid rather than a floating chip. This diverges from the Week view's small
header chip, so revert the shared-badge extraction: restore WeekScreen's
private badge and drop ui/common/WeekNumberBadge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the Week view's calendar-week badge into a shared ui/common
component and reuse it for the Month grid's week-number gutter, so the two
views show week numbers in the exact same format. The gutter now centres the
badge vertically in each row (was pinned to the day-number band) and is
widened to seat the badge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an opt-in left gutter to the Month grid showing the ISO calendar-week
number, gated by a new "Week numbers" display setting (default off). The
number is computed on each row's first day — the same basis as the Week
view's badge — so the two views agree, and rendered as a low-emphasis
onSurfaceVariant label so it recedes across all six rows rather than
competing with the event bars. The weekday header reserves a matching
gutter so the day columns stay aligned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump versionName to 2.14.0 (versionCode 21400) and cut the changelog for
the day-view-on-date-header-tap feature (#37). Merging this to main
triggers the release pipeline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fake now records the calendarIds it receives; two repository tests
assert exportEvents forwards a chosen subset and defaults to null (all
eligible calendars), closing the coverage gap for the per-calendar
export selector.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Restore is import, not export: offer it whenever any writable, non-
managed calendar exists (local or synced), not only when there is a
local calendar to back up. Previously the row lived inside the
export-gated block and vanished for users with only a writable synced
calendar, despite import supporting that target.
- Export-picker selection now uses rememberSaveable and is no longer
keyed on the observer-driven calendars list, so a background provider
re-emit (sync/recolor) can't silently reset the user's de-selections,
and the choice survives rotation.
- Shared calendar picker: restore the displayName fallback for a synced
calendar whose account name and type are both blank (was grouping them
under an empty header).
- Drop imports left dead by the CalendarPickerGroups extraction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ImportScreen has no nav backstack, so an unkeyed hiltViewModel() resolved
to the Activity's ViewModelStore and was retained across imports. Its
one-shot `load` guard then showed the *previous* file's parsed state on
the next import — trivially reachable now that the in-app Restore button
lets you export→restore or restore twice in one session (worst case: the
picker still holds file A, so tapping Import writes A's events after you
picked B). Keying the VM by the file uri hands each distinct file a fresh
VM (fresh Loading state); the same uri (rotation) reuses it and holds the
result.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tapping a date header in the Week (day-of-week column) and Agenda
(sticky section) views now drills into that date in Day view, mirroring
the Agenda widget's header behaviour. Both reuse the existing onOpenDay
callback (pendingDayIso + drillToDay) that Month already used, so the
back stack lands on Day with the tapped view as its parent.
Month already navigated on any cell tap (the transparent tap layer sits
above the day number), so no change was needed there — all four views
now behave consistently.
Closes#37
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pre-selected target was calendars.first() (raw provider order), which
could land on a synced calendar mid-list while the picker shows local
calendars first. Default to the first local calendar so the checkmark lines
up with the top row; fall back to the first calendar when none are local.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the multi-event import's confirm button into the app-bar actions so
it's reachable without scrolling past a long calendar list, and put the
count in the title ('Importing 5 events') instead of a separate 'N events
in this file' line. Hoists the selected target calendar to the screen so
the top-bar action can read it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route by entry point, not just event count. Opening a .ics from outside
still sends a single event straight to the prefilled create form (add one
event, e.g. a ticket). The in-app 'Restore from .ics' button passes
forceMany so even a single-event backup goes through the calendar picker +
summary — its intent is 'restore a backup', not 'add this event'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The import de-dups by UID against the target calendar (idempotent restore),
so re-importing events already present shows a low 'Added' count. Add a note
under the title when any were skipped so the outcome doesn't read as broken.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping Export with more than one exportable calendar now opens a picker
to choose which local calendars to include (all selected by default); a
single calendar exports straight through as before. Threads an optional
calendarIds filter through exportEvents/exportableEvents (null = all
eligible), so the auto-backup path is unaffected. The backup section is
now gated on there being at least one exportable (non-managed) calendar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Symmetric with the export change: the contact-derived, editor-locked
special-dates mirror calendars aren't a valid import destination, so drop
them from the target-calendar picker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The contact special-dates mirror calendars (birthdays/anniversaries) are
derived from contacts and re-materialise from the contact sync, so backing
them up only duplicates events on restore. Skip managed calendars in
exportableEvents — covers both the manual export and the auto-backup, which
share this path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the plain centered text list with an M3 Expressive success state:
a tonal check badge that springs in, the headline, and big-number tonal
stat tiles for added / duplicate-skipped counts, with a full-width Done
button. Stat tiles carry the full-sentence plurals as accessibility
labels so TalkBack still reads 'Imported N events'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract CalendarPickerGroups into ui/common: the calendar-manager screen's
grouped-card system (device chip for local calendars, the owning app's
launcher icon per synced account, colour chip + check per calendar) as a
single reusable picker. Use it in both the event editor and the .ics import
screen so all 'which calendar' lists match.
Moves LeadingAvatar/SourceLogo/curatedSourcePackage out of CalendarsScreen
into common as the shared source of truth. Drops the redundant 'Add to
calendar' caption from the import picker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 'Add to calendar' picker rendered bare OptionCards with no calendar
colour and no account grouping. Reuse the same account-grouped GroupedRow
layout as the event editor's calendar picker — coloured chip per calendar,
account sub-headers, a check on the selected row — so it matches the rest
of the app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a 'Restore from .ics file' row to the Calendars backup section, next
to Export. It opens a SAF document picker and routes the picked Uri into
the existing import flow (parse, dedup by UID, target-calendar picker,
summary) via CalendarHost's importUri — the same path an externally
opened .ics already takes, so no new import machinery is needed.
Closes#32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Patch release bundling the fixes for #34 (pre-1970 recurring events could
not be opened) and #27 (time-picker dial ignored the 24h setting), plus
#30 (create events from external ACTION_INSERT launches). Bumps
versionName to 2.13.1 (versionCode 21301) and cuts the changelog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
toEventDetailCore returned null when a present DTEND preceded DTSTART, the
only remaining false-drop that surfaces as the generic "Something went
wrong." error screen — the same un-openable trap as the pre-1970 DTSTART
bug, and worse because the user can't even open the malformed event to fix
it. Clamp the end to DTSTART (a zero-length event) instead, matching how
SearchMapper already coerces its end. After this the detail mapper drops a
row only when DTSTART is genuinely absent (unrenderable).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Register an intent-filter for ACTION_INSERT on the events dir mime type
(vnd.android.cursor.dir/event), the way the AOSP calendar accepts one, so
other apps and widgets (e.g. the Todo Agenda widget) can launch Calendula
to create a new event.
MainActivity.insertFormOrNull parses the standard CalendarContract extras
(EXTRA_EVENT_BEGIN_TIME/END_TIME/ALL_DAY, Events.TITLE/DESCRIPTION/
EVENT_LOCATION/RRULE) into a prefilled EventForm via the pure, unit-tested
buildInsertEventForm — omitted fields fall back to the same defaults the
in-app "new event" uses (next full hour, +1h). The form is routed through
the existing single-event prefill channel (RootScreen → CalendarHost →
the create form for review), with calendarId left null so it resolves to
the last-used / first-writable calendar. No new permission is needed
(WRITE_CALENDAR is already held), and the user still explicitly saves.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The event-form (and Settings) time picker seeded is24Hour from the
system TIME_12_24 override / device locale, ignoring the app's own
TimeFormatPref. So with the app set to 24h under an English locale the
dial still showed AM/PM, while every time label (which reads
LocalUse24HourFormat) showed 24h.
Seed the picker from LocalUse24HourFormat — the app-wide clock
convention already resolved once at the root from TimeFormatPref — so the
dial matches the labels. Drops the now-unused deviceUses24HourClock
helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DTSTART is stored as UTC epoch millis, so a recurring series anchored
before 1970-01-01 (common for yearly birthdays/anniversaries synced over
CalDAV) has a legitimately negative DTSTART. The detail and search
mappers dropped any row with dtstart < 0, and since the detail query
reads the series-master DTSTART (the ancient anchor), every occurrence of
such a series became un-openable — surfacing as the generic
"Something went wrong." error screen — and the events vanished from
search too.
Relax the guard to reject only an *absent* DTSTART (isNull), which is the
malformed case it was meant to catch; negative epoch millis flow through
correctly (Instant/formatting and the all-day reminder decode are all
Long-based). Add regression tests for both mappers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the accumulated [Unreleased] section under [2.13.0] — 2026-07-03,
adding the entries that were still missing: Spanish + Italian community
translations (thanks kikerw and corrent via Weblate), custom fonts (#19),
configurable quick-switch cycle + drawer order (#24), and the scrollable
event-editor calendar picker (#29). Regenerate fastlane/.../changelogs/
21300.txt via scripts/sync_changelog_to_fastlane.sh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The source key was removed on this branch; the Weblate translations
merged from main still carried it, tripping check_translations.py and
fatal ExtraTranslation lint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Community translations for es and it reached ~85% coverage (Weblate
PR #52 into main). Adding their locale entries makes them selectable in
the in-app picker and Android 13+ per-app language settings once that
PR is merged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Weblate owns all translation files including values-de; devs only touch
the English base. Reverts the hand-added special-dates strings and the
permission_rationale_body rewording to main's version (texts recoverable
from 1ff4115, to be re-added through the Weblate API). Keeps the removal
of the stale reminder_custom_with_value key, whose source string was
deleted on this branch (ExtraTranslation is fatal).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full-screen selection pickers opened with the LargeTopAppBar expanded, so the
title started below the bar and only settled into it after a scroll — a tall
empty header above a short option list. Give CollapsingScaffold a largeTopBar
flag and have FullScreenPicker use a pinned single-line TopAppBar, so every
picker (calendar, theme, week start, language, reminder, agenda range) shows
its title in the bar from the start. Settings and the calendar manager keep
the large collapsing header.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The calendar picker was an AlertDialog holding a non-scrolling Column, so
accounts with many calendars overflowed the dialog's fixed height and the
entries past ~9 were unreachable (Codeberg #29).
Replace it with the app's FullScreenPicker (collapsing scaffold + scrollable
content), rendering calendars as connected GroupedRows grouped under their
owning account — the same grouping the visibility filter and calendar manager
use — with a colour chip per row and a check on the current selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The periodic job and the immediate 'Sync now'/foreground runs had no mutual
exclusion, so two overlapping reconciles could each see no managed calendar
and both create one (duplicate 'Birthdays', doubled events); likewise a
disable-teardown racing an in-flight sync got its calendars recreated right
after deletion. A lifecycle Mutex now makes sync() and teardown() atomic, and
sync() re-reads the enabled flag inside the lock so a teardown always wins.
- The foreground resume trigger shared a unique work name with enable/'Sync
now' under ExistingWorkPolicy.REPLACE, so a debounced foreground enqueue could
cancel-and-swallow a pending enable sync (feature on, no calendars for up to a
day). It now uses its own work name.
- doWork() mapped every exception to retry(), so a revoked calendar permission
retried with backoff forever and never surfaced. A SecurityException now parks
the feature in the stalled state instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mirror keyed each event on (contact, type) only, and the read deduped on
that same pair from an unordered query. So a contact with two custom dates
('Wedding', 'Graduation') mirrored just one — and, the winner being
order-dependent, the single event could ping-pong between the two dates across
syncs. A Custom date's UID now carries a discriminator (its label, else its
month-day), the read dedups on the full UID, and the query is ordered by
Data._ID so a genuine duplicate resolves the same way every time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two provider-level bugs in the managed-event data path:
- queryManagedEvents and applyManagedCalendarReminders matched every event in
the calendar (UID_2445 IS NOT NULL / no filter). A stray user event there
(e.g. an .ics import) was treated as 'existing but not desired' and deleted,
or had its own reminders wiped and all-day-re-encoded. Both now match only
our own mirror events (the 'contact-' UID prefix).
- All-day reminder offsets were sampled at the event's DTSTART, which for a
year-less birthday is the 1972 leap anchor — a year whose timezone offset
(pre-DST) differs from today's, skewing every modern occurrence by up to an
hour. The offset is now sampled at the upcoming occurrence (nextYearlyOccurrence),
leaving only the inherent ±1h DST drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Managed special-dates calendars were only recognised by an id cached in
preferences, and were offered as ordinary write targets — so a user could
create an event in 'Birthdays' (which the next sync then deleted), and after
a backup restore wiped the prefs the editor lock silently disappeared.
CalendarSource now carries isManaged, read from the durable CAL_SYNC2 marker
the data layer already stamps, so identity survives a restore. With it:
- the new-event calendar picker excludes managed calendars (no user events
land in a calendar the sync owns);
- the editor lock keys off the marker, not a stored id;
- a managed event's save is forced to the whole series instead of parking in
the scope dialog (a 'this/following' split creates rows the sync reverts);
- the detail sheet hides Delete for managed events (the sync would just
resurrect them — a contact date is removed at its source).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four fixes across the settings/prefs layer:
- Quick-switch toggles and reorders were read-modify-write against the
async-echoed UI snapshot, so two rapid interactions reverted each
other. Writes now go through SettingsPrefs.updateQuickSwitch, an
atomic transform over the currently-stored value, via intent-level
ViewModel ops; the MIN_ENABLED floor is re-checked inside the
transform since the screen's guard reads the stale snapshot.
- parseReminderOverrides treated any unparseable stored value as an
explicit empty override (no reminder). Only the deliberate 'none'
sentinel means that now; garbage drops the entry so the calendar
inherits the global default. Partially-valid values salvage their
valid minutes.
- Replacing an already-active custom font never refreshed typography:
the unchanged 'custom' token made AppFontSettings value-equal, so the
StateFlow never re-emitted. A per-role import stamp now breaks
equality on re-import (missing key = 0, backward compatible).
- The FontPicker custom preview resolved the font file unmemoized on
every recomposition (disk stat + fresh FontFamily defeating the
typeface cache); it's now remembered, keyed on the import stamp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The order mutation and onReorder call sat after the 160 ms settle
animation inside the same cancellable coroutine; grabbing another row
within that window cancelled the job between animation and commit,
silently reverting a finished reorder. The commit now runs
synchronously in onDragEnd — the settle is purely visual (the live
offset is re-based onto the new slot in the same frame, so nothing
jumps) and safe to interrupt.
Also derive the hovered target slot via derivedStateOf so a drag only
recomposes the column when a slot boundary is crossed, not on every
pixel (the held row's own translation is already draw-phase).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three defects in the v2.13.0 multi-select reminder picker:
- Unchecking the last time on a per-calendar picker silently persisted
an explicit no-reminder override; an accidental toggle-undo wiped the
calendar's default. Empty-by-unchecking now reverts to 'Use default'
(Inherit); deliberate no-reminder is its own exclusive 'None' row
(reusing reminder_none) on both pickers, so the empty state is
visible and reachable instead of implicit.
- A custom (non-preset) lead time's row vanished the moment it was
unchecked, stranding the hand-entered value. Custom values seen this
session keep their row (unchecked) until the picker closes.
- The optimistic selection seeded once from a possibly-not-yet-loaded
settings state (initialValue emptyList behind a CalendarProvider-
gated combine), so a quick first toggle after process-death restore
overwrote the stored default. The local state now re-syncs from the
incoming selection until the user first interacts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two holes in the v2.13.0 'disabled calendars no longer notify' fix:
- A snoozed reminder bypassed the filter: ReminderActionReceiver's
ACTION_SHOW posted unconditionally. The gate now lives once in
ReminderNotifier.post — the single choke point both receivers use —
built on a shared ReminderAlert.isForDisabledCalendar predicate that
never treats calendarId 0L (pre-upgrade snooze intents without
EXTRA_CALENDAR_ID) as disabled.
- A reminder firing while its calendar was disabled was lost forever:
the receiver marks the full due set STATE_FIRED (deliberately, to
stop provider re-broadcasts) and nothing re-scans. Suppressed alerts
are now stashed in a DataStore-backed SuppressedReminderStore and
re-posted when the calendar is re-enabled, while the event hasn't
ended yet; expired entries are purged opportunistically.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The worker no longer pre-checks specialDatesEnabled: the engine already
gates on it, and the worker now consumes the previously-unreachable
Disabled result instead (without recording a run).
- ReminderContext drops the timed defaults it could never use — managed
events are always all-day — saving two DataStore reads per sync.
- createCalendar no longer persists the calendar id itself;
reconcileCalendars is the single place ids are recorded (it always
followed up with the same write).
- The title-template fallback uses the shared resolveTitleTemplate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- choiceFor moves next to CalendarReminderOverride in SettingsPrefs and
replaces the hand-rolled null/empty/else decoder in SettingsViewModel,
so the picker and the special-dates rows can't drift apart.
- The special-dates template dialog used the codebase's only
OutlinedTextField; it now follows the documented dialog convention
(InlineTextField over a tonal surface, see DialogControls).
- reminderChoiceLabel drops the manual loop (and its wrong comment —
map is inline, so composable calls are fine in its lambda).
- The blank-template fallback is shared with the sync engine via
resolveTitleTemplate instead of being copy-pasted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The READ_CONTACTS check was written out three times (data source, RootScreen
resume trigger, settings screen). A single extension in the contacts package
mirrors the existing Context.hasCalendarPermission() precedent; the settings
call site follows in the settings cleanup commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndroidSpecialDatesCalendarSpec hardcoded three ARGB literals that its KDoc
claimed were 'picked from the shared palette' — a silent-drift hazard, and
referencing ui.common from data would invert layering. The palette now lives
in data/calendar as CalendarColorPalette with named hues; the swatch pickers
use CalendarColorPalette.all and the spec references Purple/Red/Blue.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
insertManagedEvent duplicated ~45 lines of insertEvent (ContentValues build
and the reminder-seeding loop). Both now go through buildEventInsertValues
in EventWriteMapper and a shared seedReminders, differing only in the UID
they stamp and the colour/attendee columns insertEvent layers on top.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
German translations must flow through Weblate; strings committed from
code conflict with Weblate's repo state on its next merge. The nine
font-picker strings from the custom-fonts feature fall back to English
until translated in Weblate (MissingTranslation is informational).
The stale-key removal (reminder_custom_with_value) stays: ExtraTranslation
is a fatal lint error, so deletions of retired source strings must
accompany the code change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restrict the "Choose file…" document picker to font MIME types
(font/ttf, font/otf, font/sfnt, font/collection plus legacy
application/* font aliases) instead of */*, so only fonts are
selectable. CustomFontStore's Font.Builder validation still guards
anything a provider mislabels.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a per-role font picker in Appearance settings. The two Material
typeface roles can be set independently: brand (display/headline) and
plain (title/body/label). Each can be the system default, a bundled
font (Atkinson Hyperlegible, Lora, JetBrains Mono), or a .ttf/.otf the
user loads from the device. The choice rebuilds the app-wide typography
in MainActivity; a null/"system" choice leaves the Material default.
Per the issue discussion the font-size and font-colour parts were
dropped — Android's accessibility font scaling and the dynamic-colour
theme already cover them.
Custom files are copied into app-private storage and validated with
android.graphics.fonts.Font.Builder before replacing the previous one,
so a bad pick can't wedge global text rendering; a missing/unreadable
file degrades to the system font. Each bundled font is previewed in its
own face in the picker. OFL licences + attribution vendored under
licenses/fonts/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework ReorderableColumn: the first cut measured each row, but grouped
cards have position-dependent padding, so slot heights varied and rows
snapped to the wrong places with no animation. Now rows are a fixed
uniform pitch, the target slot is the whole-pitches dragged, neighbours
slide aside with a spring, and the held row settles onto its slot before
the order commits. Add a lift (scale + shadow) on the held row and
cancel an in-flight settle if a new drag pre-empts it.
GroupedRow gains gapBelow so the reorderable list can own uniform
inter-row spacing.
On-device verified on Pixel 10.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A FREQ=YEARLY event has one static title, so a per-occurrence age is impossible
without heavy per-year exception rows. Replace {age} with {year} — the birth
year (or an anniversary's start year) — which is static and correct on every
occurrence, and shows everywhere (widgets, other apps, exports).
- renderSpecialDateTitle substitutes {year}; drop the age snapshot computation
and the sync `today` parameter.
- Default templates become "{name}'s birthday ({year})" / "…anniversary ({year})".
- Rename the setting to "Show year" (prefs specialDatesShowYear) and remove the
now-unneeded {age} snapshot disclaimer in the template editor.
- Update tests, CHANGELOG and the design-doc note.
lint + unit tests + assembleDebug green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Title format" row was only editable for Birthdays; move it into each
per-type card so Anniversaries and Other dates get their own editable template
too (the dialog was already keyed by type). Each enabled type card now reads
toggle → title format → reminders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Managed calendars now treat reminders as a calendar-level setting instead of the
seed-once-per-event model, matching how these homogeneous birthday/anniversary
calendars are actually used.
- New per-type "Reminders" control in the Contact special dates section. Changing
it persists the per-calendar all-day override (so new events match) AND
re-applies the set to *all existing events* in that calendar
(CalendarDataSource.applyManagedCalendarReminders → SpecialDatesSyncEngine
.applyReminders), encoding each event's all-day offset from its own date.
- Settings → Notifications: the contact-date calendars no longer offer a
per-calendar override row; they show a link that jumps to the Contact special
dates section (managedCalendarIds now in SettingsUiState).
Tests cover applyReminders (override persisted + bulk-apply invoked, and None
clears). lint + unit tests + assembleDebug green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously only the small day-number was a tap target, so tapping the
blank area of a day cell (or its "+N" overflow) did nothing. Make every
non-event part of a day column open that day — the day number, the empty
lane cells, and the overflow row — via a shared openDayAction, so a tap
anywhere on a day opens it, matching the in-app month grid. Event bars
keep opting out to open their own detail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Views" settings section that lets users pick which views the
top-bar quick-switch button cycles through and drag to reorder them,
plus an independent drag-to-reorder for the navigation drawer's view
list. Two separate configs: a view turned off in the quick-switch
cycle is still reachable from the drawer, which always lists every view.
- QuickSwitchConfig (order + enabled set) and a drawer order persisted
in SettingsPrefs (comma-joined enum names; "!" marks a disabled view).
Missing views append enabled and unknown names drop, so a future view
defaults into both lists.
- The pill cycles through the configured enabled views in order; the
drawer renders CalendarDrawer from the drawer order. Both threaded
from CalendarHost via CalendarHostViewModel.
- New ReorderableColumn: dependency-free, measurement-driven drag
reordering for the short grouped-card settings lists (no LazyColumn,
since settings are a single verticalScroll column). Commits one write
per gesture.
- The switch needs two targets, so the last two enabled views can't be
turned off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping a widget header now opens the app on a sensible top-level view,
via a shared WidgetNavRequest.OpenView(view?) — a concrete view roots
there over the default home, a null view resolves to the default home:
- Month widget: the month/year title opens the month view (#18). Its
"today" button now snaps the grid back to the current month in place
(the reset that used to sit on the title), so paging + jump-to-today
are both reachable and the title is free to open the app.
- Agenda widget: the "Upcoming" title opens the default view (#20), so
users whose home view is Week/Month can reach it in one tap instead of
drilling through a day or event.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The month widget's prev/next/today controls and the agenda widget's
refresh run through Glance `actionRunCallback<T>()`, which persists the
callback's class name into the click PendingIntent and instantiates it
reflectively when tapped. Under R8 full mode (AGP 9 default) those
callback classes — only ever referenced reflectively — were renamed and
lost their no-arg constructor, so the lookup failed silently and the
arrows did nothing on release builds (while actionStartActivity taps,
which need no reflection, kept working).
Keep every ActionCallback's name and constructor. Verified against the
release mapping: ShiftMonthAction/ResetMonthAction/RefreshAgendaAction
now retain their original fully-qualified names.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A FREQ=YEARLY event has one static title, so {age} is only a sync-time snapshot
and can look wrong on far-future occurrences. Keep it out of the default
templates ({name}'s birthday / anniversary), and when a user does add {age} in
the title-format editor, show a short disclaimer about the snapshot behaviour.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the UID_2445 (vs SYNC_DATA1) identity decision, the sync-time age
snapshot, and the seeded reminder default; checks off the #14/#15 task lists.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- German translations for the special-dates sub-page, the managed-field editor
hint, and the calendar/title strings.
- Store listing (en/de): a paragraph on the optional, on-device, one-way contact
special-dates feature.
- Soften the calendar-permission rationale ("all it asks for up front") now that
an optional contacts permission exists, without weakening the privacy promise.
- CHANGELOG entry for #15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A dedicated sub-page under Settings drives the mirror:
- master enable toggle — requests READ_CONTACTS contextually (only here, never at
startup), runs an immediate sync on enable, and on disable confirms then tears
the managed calendars down;
- per-type toggles (Birthdays / Anniversaries / Other), each confirm-guarded
since turning one off deletes its calendar;
- an editable title template ({name}/{age}) and a Show-age toggle;
- a "Sync now" row with the last-run time, a paused/permission banner with a
re-grant button, and a pointer to per-calendar colour/visibility/reminders.
Exposed via a separate SpecialDatesUiState flow (the main settings combine is
already at capacity); the ViewModel gained the engine/scheduler wiring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When editing an event in a special-dates calendar, the title, all-day/date and
recurrence are locked (the sync overwrites them) while reminders, location, notes
and colour stay editable — the field-level "managed" contract from the design.
- EventEditUiState.isManaged (calendar id ∈ managedCalendarIds while editing),
wired through the ViewModel's state combine.
- InlineTextField/InlineField gain an `enabled` flag (dims + goes read-only);
ScheduleRow dims and disables its date/time taps; the all-day switch and the
recurrence card are disabled.
- A one-line "Managed by …" hint under the title explains what's editable.
The calendar picker needs no change — it's already create-only, and managed
events are always edits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the auto-backup infrastructure. A daily periodic WorkManager job plus an
immediate run on enable/"Sync now" and a debounced foreground run keep the mirror
fresh without a ContentObserver.
- SpecialDatesScheduler.apply/runNow + SpecialDatesSyncWorker (EntryPoint):
respects the toggle, debounces foreground runs (4h), and parks the feature in a
PermissionRevoked stalled state (recorded for the settings banner) instead of
retrying forever when READ_CONTACTS is gone.
- CalendulaApp.onCreate reconciles the schedule on every launch.
- RootScreen ON_RESUME enqueues a debounced foreground sync, gated on the
READ_CONTACTS grant so opted-out users never enqueue it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The engine reconciles device contact dates into the per-type local calendars.
Each run is an idempotent diff keyed on the deterministic UID_2445:
- new contacts inserted (seeding reminders via resolveDefaultReminder, plus a
useful per-calendar all-day default of on-the-day + a week before so birthdays
get lead time out of the box);
- changed contacts get a targeted managed-column update (title/dtstart/rrule
only) — reminders/location/notes are never re-touched, so user edits survive;
- removed contacts deleted.
Managed calendars are created/adopted/removed per enabled type (reconcileCalendars,
self-healing against a stored-id that no longer exists), all-day FREQ=YEARLY
events anchored at the birth year (or a leap anchor when year-less). Pure helpers
(uid, anchor, age snapshot, title templating) and the diff are extracted for
unit testing; a stateful fake exercises full-run idempotency and user-data
preservation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the SettingsPrefs surface the mirror is configured and reconciled through:
master enable, per-type toggles (default all three on), per-type managed calendar
id (source of truth for the editor's managedCalendarIds and the sync targets),
editable per-type title template, show-age, and a run/stalled status plus a
foreground-sync debounce timestamp.
Calendar ids are stored per type under their own long key (no serialization/
escaping); templates likewise per type. SpecialDatesStatus/StalledReason model
the paused (permission-revoked) state for the settings banner.
Extends the test FakeCalendarDataSource with a stateful managed-event surface
(inserts reflected in later queries) so the sync engine can be exercised for
idempotency. Round-trip tests for every new pref.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The special-dates mirror needs a durable, user-invisible per-event key so a
re-sync diffs instead of duplicating. Rather than Events.SYNC_DATA1 (which the
provider drops on a LOCAL calendar unless written via a sync-adapter URI), the
key is a deterministic UID_2445 (contact-<type>:<lookupKey>@calendula), written
and read through the normal event URIs the app already uses.
- createManagedCalendar: a normal local calendar plus a CAL_SYNC2 marker, so the
mirror can re-adopt its calendars after a prefs wipe (findManagedCalendars).
- queryManagedEvents: reads back _ID/UID_2445/TITLE/DTSTART/RRULE (DELETED=0) as
the existing side of the diff.
- insertManagedEvent: writes the deterministic UID and seeds reminder rows once.
- updateManagedFields: a bare, targeted column update — never reconciles
reminders/attendees, so user-owned event data survives every sync.
Reuses toWriteTimes/toRfc2445Duration (all-day UTC-midnight DTSTART + P1D
DURATION for the FREQ=YEARLY row) and the existing deleteEvent for removals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codeberg #15, foundation for the contact special-dates calendars. Declares the
optional, feature-gated READ_CONTACTS permission (never requested at startup)
and adds the offline, read-only contacts reader.
- AndroidManifest: READ_CONTACTS with a comment documenting the opt-in/offline
one-way-mirror contract.
- domain/contacts: SpecialDateType + ContactSpecialDate model and a pure
parseContactEventDate covering full (yyyy-MM-dd), year-less (--MM-dd) and
compact (yyyyMMdd) shapes, with Feb-29 handling via a leap anchor.
- data/contacts: ContactSpecialDatesDataSource querying ContactsContract.Data
Event rows, split by TYPE, deduped per (contact, type); returns empty without
the permission so sync can degrade to a stalled state. Hilt-bound.
- Unit tests for the date parser (full/year-less/compact/Feb-29/malformed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the disabled-calendar filtering from EventReminderReceiver into a
pure postableAlerts() function and unit-test it, since the receiver flow
itself is not reachable from a plain JUnit test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reminders fired for every due CalendarAlerts row regardless of whether
the event's calendar had been disabled in-app, even though disabled
calendars are hidden from every other surface. Carry the calendar id
through the alert and filter disabled calendars before posting, while
still marking all due alerts fired so the provider stops re-broadcasting
the suppressed ones.
Closes#17
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an [Unreleased] section covering the two user-facing changes in
release/v2.13.0: multiple default reminders per calendar (Codeberg #14,
@moonj) and the fix for editing a single occurrence of a recurring event
(Codeberg #16). The debug-build ribbon is dev-only and intentionally omitted.
Define the [#14] and [#16] links.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codeberg #16: 'only this event' recurrence edits now write a DURATION-based
exception (DTEND is rejected by the provider). Includes debug-build markers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make a debug install unmistakable next to the production app, Flutter-style:
- src/debug resource overrides: "Calendula Debug" label and a burnt-orange
launcher-icon background (production stays slate). The .debug applicationId
suffix already lets both install side by side.
- DebugRibbon: a "DEBUG" corner ribbon overlaid in MainActivity, gated on
BuildConfig.DEBUG so release/releaseTest never show it. Enables the
buildConfig feature for the flag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Saving "only this event" on a recurring event inserts a modified-occurrence
exception at Events.CONTENT_EXCEPTION_URI. buildOccurrenceExceptionValues set
the occurrence's end as DTEND, but the provider rejects that outright —
CalendarProvider2.checkAllowedInException throws
IllegalArgumentException: Exceptions can't overwrite dtend
so the insert failed, the save surfaced as SaveUiState.Failed, and the edit
screen reappeared with no change applied. "This and following" and "all events"
never go through the exception insert, which is why only "only this event" broke
(Codeberg #16, verified on a Pixel 10 / Android 16).
An exception is a single instance whose end the provider derives from
DTSTART + DURATION (clearing the inherited RRULE itself), so carry the length as
DURATION and drop DTEND — the same shape AOSP Calendar/Etar use. Verified
on-device: editing one occurrence now applies to just that occurrence and leaves
the rest of the series untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codeberg #14. Event-level multi-reminders already worked; this widens the
*defaults* layer from a single Int to a List<Int> so a calendar's default can
carry several lead times (e.g. a birthday calendar: one week before *and* on
the day).
- SettingsPrefs: global + per-calendar timed/all-day defaults become List<Int>;
CalendarReminderOverride.Minutes(List<Int>); resolveDefaultReminder returns a
list. Storage stays backward-compatible — a legacy single value ("30") parses
to [30], comma-joined for multiples, "none"/empty for no reminder.
- ReminderDefaultPicker is now multi-select: M3 Checkbox rows over the existing
grouped-tonal idiom, an exclusive "use default" group for per-calendar
overrides, and a Custom row that adds an arbitrary lead time to the set.
Optimistic local state so quick successive toggles don't race the settings
flow round-trip.
- New events seed their reminder list from the resolved default.
Tests cover none/single/multiple round-trips, legacy single-value parsing, and
per-calendar list overrides. lint + test + assembleDebug green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the full design for surfacing contact birthdays/anniversaries/custom
dates as auto-updating local calendars (Codeberg #15), plus the #14
prerequisite (per-calendar multiple default reminders). One local calendar per
type to reuse existing per-calendar color/visibility/reminder infra; one-way
mirror with field-level managed events keyed on contact LOOKUP_KEY.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the accumulated [Unreleased] section under [2.12.0] — 2026-06-28 and
regenerate fastlane/.../changelogs/21200.txt from it (scripts/
sync_changelog_to_fastlane.sh), so the official F-Droid listing and the
Gitea release notes both cover the release's two changes: the past-event
display settings (agenda screen + widget, month/week dimming) and the
overlapping-event title wrapping. versionName is already 2.12.0/21200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Issue #12 asks for hiding finished events in the agenda view *and* its
home-screen widget. The in-app screen already honours the Past events
setting (Show / Dim / Hide); extend the same setting to the "Upcoming"
agenda widget.
The widget loads its events unfiltered and applies the mode reactively in
the composition against a captured "now" — mirroring how the range is read
from per-instance Glance state — so toggling the setting reflects on a live
widget without relying on provideGlance's data preamble re-running. Hiding
drops finished events and any day they empty; dimming fades the colour
stripe and lowers the title's emphasis (Glance has no generic alpha
modifier). setPastEventDisplay now pushes the mode into each widget's Glance
state and recomposes, like setAgendaWidgetRange.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The overlapping-title fix already shipped its F-Droid note in
fastlane/.../21200.txt but was never added to CHANGELOG.md, so the
release-cut sync (which regenerates the fastlane note from the changelog's
version section) would have dropped it. Add it under [Unreleased] › Changed
alongside the past-event display settings, and define the missing [#13] link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump versionName to 2.12.0 (versionCode 21200) and add the changelog.
Merging this into main triggers release.yaml, which builds, signs,
publishes and mints the v2.12.0 tag + Gitea release.
Closes#13
Overlapping events split a day column into narrow lanes where the title
was clipped to one or two characters. Mirror Google Calendar: drop the
time label on overlapping (multi-lane) blocks and wrap the title across
as many lines as the block height allows, so the full title stays
readable without opening the event.
Closes#13
Adds two independent display settings under Settings › Appearance, both
defaulting to the current behaviour (off) so nothing changes until a user
opts in:
• Agenda › "Past events" (Show / Dim / Hide) — events that already ended
today can be left as-is, faded, or dropped from the list. Hiding also
removes any day left empty, falling back to the empty state. Re-evaluated
each minute so rows fade/fall away as they end while the screen is open.
• Calendar › "Dim completed events" — a separate toggle that fades finished
events in the month and week grids, kept independent of the agenda setting.
An event counts as completed once its end is at or before now (in-progress
events are never dimmed; all-day events only after their day is fully over),
via a shared EventInstance.hasEnded(now). The grids read the cut-off through
a new LocalDimCutoff CompositionLocal (mirroring LocalShowHourLines) so only
the event chips recompose on the per-minute tick, and only while dimming is on.
Also adds an "Agenda" section header so the agenda rows stand apart in the
now-busier Appearance screen, and documents the feature in the changelog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening the new-event form now puts the cursor in the title field and raises the
keyboard, so the user can type the name straight away instead of tapping the
field first (issue #10). On by default — most events get a title — with a new
"Focus title on new event" switch in Settings → New event form to turn it off.
Only the create form auto-focuses: editing an existing event and opening a
prefilled/imported form never grab focus (guarded by !isEditing && title blank).
- SettingsPrefs: autofocusEventTitle (booleanPreferencesKey), default true.
- Plumbed through SettingsViewModel/UiState (settings switch) and
EventEditViewModel/UiState (read by the form).
- EventEditScreen: a FocusRequester on the title InlineField, requested once per
open from a LaunchedEffect when the guard holds.
- Strings (en + de), unit test for the new pref default/round-trip.
Bumps to 2.11.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Calendula didn't declare the intent filters launchers and the system use for
calendar actions, so it never appeared in the "default calendar app" chooser —
on every platform, not just GrapheneOS (issue #9). Android exposes no API for an
app to set itself default, so registering these filters is the only way users can
pick it from the system picker.
Adds to MainActivity:
- MAIN + APP_CALENDAR — the "open the calendar app" action the OS/launchers use.
- VIEW on content://com.android.calendar/time/<epochMillis> and the time/epoch
mime type — a launcher/clock date tap. The provider's time Uri is parsed into a
LocalDate and opened on the day view, rooted over the default home view (a new
sourceless WidgetNavRequest.OpenDate). The .ics import path now ignores the
calendar provider host so a date tap isn't mistaken for a file to import.
Bumps to 2.11.1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review, it shouldn't be a top-level Settings entry: the "Help translate"
link now sits at the top of the full-screen App language picker (OptionPicker
gains an optional header slot). Updated README + changelog wording to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Invite community translations: a new Settings > Help translate row (next to
App language) opens the project's Weblate engage page, plus a Translations
section in the README. Documented in the 2.11.0 changelog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the Calendars manager redesign, Notifications reorg, QS-tile move,
event-form field icons, and automatic local-calendar backup (#8) to the
2.11.0 changelog; sync the F-Droid per-version changelog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turning automatic backup off only cancelled the periodic work, so a
run-now that kept failing (e.g. its folder was deleted) retried forever
and spammed failure notifications.
- Cancel the immediate "run now" work too when backup is disabled.
- Worker no-ops (no retry) when the toggle is off, so already-queued work
can't revive itself.
- Reconcile scheduled work against saved settings on every app launch —
re-arms after a reinstall and clears orphaned work once backup is off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scheduled one-way export of local calendars to a user-chosen folder,
overwriting calendula-backup.ics each run — the manual backup, automated.
Not a sync; stays INTERNET-free (provider reads + local file write).
- WorkManager periodic job (deps: work-runtime-ktx, documentfile); worker
pulls collaborators via a Hilt @EntryPoint, so no custom WorkerFactory
wiring. First periodic run is delayed one interval so it can't race the
immediate "run now" feedback run; the writer also overwrites the canonical
file and cleans up any "(1)" duplicates from earlier races.
- SettingsPrefs: enabled, interval (minutes, floored at 30), folder Uri,
last-run status; persisted SAF write grant.
- UI in Calendars > Backup: toggle, folder picker, amount+unit interval
dialog, last-run status line. Notifies after repeated failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the per-account toggle switch and nested manage row with a single
trailing overflow (⋮) menu holding both account-level actions:
- "Enable all" / "Disable all" — toggle every calendar in the group
- "Manage in app" (synced) / "Add calendar" (local)
The dropdown is styled to fit: rounded corners, a distinct floating
surface (surfaceContainerLowest + lifted shadow) so it stands clear of
the cards, and a divider separating the two actions. Shortened the manage
label to "Manage in app" and dropped the now-unused account a11y strings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Settings:
- Event-form field rows show each field's own icon (shared
EventFormFieldVisuals, reused by the editor and settings)
- Move "Add Quick Settings tile" to a top-level Settings hub row
- Notifications: reliable-delivery + snooze moved above the
per-calendar block, which now folds behind one expandable section
Calendars manager:
- Local and synced calendars now use one collapsible group card
- Source-branded headers: each account shows its app's launcher icon
(Google Calendar, DAVx5, …) loaded from PackageManager; local shows
a device chip
- Per-account toggle-all switch (CalendarsViewModel.setAccountDisabled)
- Management action ("Add calendar" / "Manage in app") is a nested row
inside the expanded group; removed the dead "Add account" row
- A fully deactivated account dims its header, not just the switch
- Group headers use the normal row colour; their options sit one tone
darker (GroupedRow gains an optional container colour)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expand the #4 entry to cover the in-agenda range bar (date header + session
range switch) and add a Changed note for the Appearance regrouping and the
friendlier agenda empty state. Regenerate the F-Droid changelog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agenda widget loaded and sliced its data in the provideGlance preamble
and captured it as a non-reactive local. updateAll() reliably recomposes a
live Glance session but does not reliably re-run that preamble, so a range
change was redrawn against the stale slice (intermittently — only when no
session was alive did it pick up the new range). This is the same platform
limitation the month widget already works around.
Mirror that pattern: load the widest selectable window once, store the range
in per-instance Glance state (AGENDA_RANGE_KEY), read it reactively via
currentState, and slice in the composition. The settings setter writes the
state into each instance and recomposes, so a range change now reflects via
plain recomposition regardless of session lifecycle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The debounced observer was unreliable: drop(1) plus the ViewModel lifecycle
could swallow a change after re-entering the app, so a single range change
sometimes didn't refresh the widget. Refresh directly from the settings
setters instead (always fires on a real change), serialized through a mutex
so a rapid flip-and-flip-back can't run two updateAll calls at once and
strand the widget on the intermediate value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refreshing the widget directly from the setter raced when a setting was
flipped and flipped back quickly: two concurrent updateAll calls could
coalesce around a stale read and leave the widget on the intermediate value.
Observe the agenda-widget-range and week-start prefs instead and push a
single debounced updateAll once changes settle, so the widget always
converges to the final value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agenda widget only re-read agendaWidgetRange on the next data-change /
midnight / periodic refresh (or re-placement), so a settings change appeared
to do nothing until then. Push an updateAll from the settings setters:
the agenda widget on an agenda-widget-range change, and both widgets on a
week-start change (month weekday header + the agenda widget's "this week").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidate the separate "range title" and "range button" settings into a
single "Range bar" toggle, now that the header and switcher live on one bar.
On by default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the session range pill from the bottom-left corner onto the same top
bar as the "Showing all upcoming events for …" header — header on the left,
pill on the right, each still independently toggleable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reword the range header to "Showing all upcoming events for". Replace the
dry empty state ("Nothing scheduled" + subtitle) with a single warmer line
("You're all caught up"), drop the subtitle, and switch the icon to a
coffee cup. The empty title is shared with the agenda widget.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the "Showing all events for <window>" line out of the picker and into
the agenda view as an always-visible header above the list. Add two
settings (Agenda group, both ON by default) to toggle the range header
banner and the bottom-left range pill independently. The picker keeps its
two-list grouping but no longer carries the header.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Group the range options into calendar-aligned (today / this week / this
month) and rolling (next 7 / 30 days / custom) lists. Add a header showing
the concrete span currently in effect — a single date for Today, the month
and year for This month, otherwise a start–end span — so it's clear what
the agenda is showing. The header appears only where a window is supplied
(the agenda pill), not in Settings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a tonal pill in the agenda's bottom-left corner showing the current
range; tapping it opens the existing AgendaRangePicker as a session-only
override. The override lives in AgendaViewModel (in-memory), so it survives
view switches and rotation but resets to the saved default when the app is
relaunched. The pill fills with the primary container while an override is
active, and a hint in the picker spells out the temporary nature.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the uniform two-per-card grouping for meaningful sections: theme &
colour (2), calendar — default view, week start, time format, hour lines
(4), and agenda (2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Integrate #3/#5/#6 (week-start, time-format, hour-lines) into the agenda
range branch. SettingsViewModel folds all five view prefs into one combine;
WidgetData keeps the range-based window and adds the resolved is24Hour.
Also, in the same Appearance section:
- regroup the rows into four titled-by-spacing groups (theme & colour /
calendar layout / timeline display / agenda) instead of one long card.
- add an explanatory description line under the title of each agenda-range
picker (PickerDescription).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump versionName to 2.11.0 (versionCode 21100) and add the 2.11.0
changelog: any-day week start (#3), 12/24-hour time format (#6),
optional timeline hour lines (#5), and agenda range limits (#4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend AgendaRange with calendar-aligned windows alongside the rolling
ones: ThisWeek runs through the end of the current week (respecting the
week-start preference — a Monday start means everything before next
Monday), ThisMonth through the last day of the current month. dayCount now
takes the anchor day and week-start; the agenda screen and widget resolve
the week-start preference and pass it through. Rolling options relabelled
("Today", "Next 7 days", "Next 30 days") to read distinctly from the new
calendar-aligned ones.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add independent agendaScreenRange and agendaWidgetRange preferences, each a
rolling window: 1 day / 1 week / 1 month / custom 1–365 days (default Month).
Rolling (not calendar-aligned) so the span never degenerates near a period
boundary. The in-app Agenda screen and the agenda widget each read their own
setting, configured via a new AgendaRangePicker with an inline custom-days
editor.
Closes#4 (Codeberg)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a showHourLines preference (default off — the existing clean look).
When on, the week and day timelines draw a faint outline-variant line at
each hour boundary, sitting over the column background but beneath event
blocks. The toggle is provided app-wide via LocalShowHourLines and applied
through a reusable hourSeparatorLines() modifier.
Closes#5 (Codeberg)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a TimeFormatPref (Auto/12h/24h) preference. Auto follows the device's
24-hour system setting; the others force a clock app-wide. The resolved
convention is provided once at the app root via LocalUse24HourFormat, so
every in-app time label reads it without per-screen plumbing.
A shared, pure TimeFormat helper (formatTimeOfDay / formatMinuteOfDay /
formatHourLabel / timeOfDayFormatter) is now the single source for all
time-of-day rendering. Routing every site through it also fixes a
pre-existing inconsistency: the week/day timeline gutters and the agenda
screen + widget hard-coded 24h, while event detail/edit/search/reminders
followed the locale — so a 12h-locale user previously saw mixed formats.
Covered: week & day timelines (gutter + event blocks), agenda screen and
widget, event detail/edit, search, and reminder notifications.
Closes#6 (Codeberg)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the AUTO/MONDAY/SUNDAY week-start enum with a sealed type
(Auto + Day(DayOfWeek)) so users can pick any of the seven days as the
first day of the week. The picker now lists "Automatic" plus all seven
localised weekday names; labels come from java.time display names rather
than per-day string resources.
Stored values round-trip by DayOfWeek.name, so the legacy MONDAY/SUNDAY
preferences migrate transparently. Garbage values fall back to Auto.
Closes#3 (Codeberg)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a second, heavier visibility level above the per-view hide
filter. A disabled calendar is removed from the app's surfaces entirely —
its events drop out of all views and search, and it disappears from the
drawer filter list, the event-form calendar picker and the import target
picker. It stays listed only in Settings → Calendars, where a per-row
switch toggles it, so it can always be brought back.
- CalendarPrefs: disabledCalendarIds set + setter, mirroring the hidden
set (DataStore comma-separated string); never touches the system
VISIBLE/SYNC_EVENTS flags, so it's app-local and reversible.
- CalendarRepositoryImpl: instances()/searchEvents() exclude
calendarId ∈ (hidden ∪ disabled). distinctUntilChanged() on instances
collapses the transient duplicate emission both DataStore-derived sets
produce when either is toggled.
- FilterViewModel: drop disabled calendars from the drawer filter list.
- EventEditViewModel: exclude disabled from writableCalendars; a
last-used preselect on a now-disabled calendar falls back to the first
remaining writable one.
- ImportViewModel: exclude disabled from the import target list.
- CalendarsScreen/ViewModel: per-row enable/disable Switch on both the
local and synced groups; disabled rows render dimmed (new GroupedRow
`dimmed` flag) while keeping the toggle live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update roadmap to reflect v2.8.0 releases (attendee editing, full-text
search, QS tile, now-line, contact pickers) and the week-numbers-in-month
rejection; capture the in-app 'disable a calendar' feature plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swap the Buy-me-a-coffee pill for Ko-fi's beige support badge, matched
to the F-Droid badge height (56).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The solid Ko-fi pill read heavier than the F-Droid badge at equal
height; drop it to 46 so the two feel balanced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both at height 56 on one line — equal heights align on the baseline,
so no table/border workaround is needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The table centered them but Gitea draws cell borders we can't strip.
Stack the two badges on separate centered lines instead — no borders,
each keeps its own size.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap both badges in a table row so the different-height buttons
align on a shared midline instead of the text baseline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use Ko-fi's raster PNG button (the SVG was ignored and rendered
oversized) and match both buttons to height 56 so they sit inline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the small shields Ko-fi badge with Ko-fi's official
"Support me" button, placed beside the Get it on F-Droid badge
as a peer call-to-action.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the Building section into docs/BUILDING.md and collapse Building,
Architecture, and Roadmap into a compact Documentation block linking to
the dedicated docs, keeping the README focused on what/install/support.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Move support to the top: Ko-fi badge in the header badge row,
drop the redundant bottom Support section
- Add "Get it on F-Droid" button now that Calendula is on the
official repo
- Restructure Install: lead with official F-Droid, keep the
self-hosted repo as the faster-updates lane
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an optional way for users to support development:
- About card: tonal "Support development" button opening ko-fi.com
via ACTION_VIEW (no INTERNET permission), EN + DE strings
- F-Droid metadata: Donate field in self-hosted and official drafts
- README: short Support section with the Ko-fi link
No perks/rewards attached — kept as a plain donation link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Centralise the app's motion vocabulary in CalendarTransitions.kt and route
every surface through it so animation is consistent app-wide:
- Shared expand/collapse, list-item and fade-through helpers; the event-edit
expand pattern is now the shared one (no duplicate).
- Settings reminder-override rows and the reminder Custom field now expand/
collapse instead of bare-fading.
- Search and agenda rows animate (fade/relocate) via animateItem.
- Month/week/day slide and the onboarding gates honour the new helpers.
- View switching (month/week/day/agenda) now fades through instead of
snapping — lateral navigation per M3, while in-view paging keeps its slide.
Add full predictive-back support:
- enableOnBackInvokedCallback in the manifest.
- New Modifier.predictiveBack(onBack) drives the standard preview transform
(scale/shift/round) following the back gesture; applied to detail, edit,
search, settings (+ sub-screens & calendar manager via CollapsingScaffold),
the calendar editor and import — each keeping its existing back semantics.
Reduced-motion guardrail throughout: rememberReduceMotion() (reads the OS
"remove animations" setting, which Compose ignores by default) collapses
spatial motion to a quick fade and skips the back preview.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-device review follow-ups:
- Month widget grid is now tappable. Day numbers open that day and event
bars open the event's detail, both rooted in the month view so back
returns to the grid. Previously only the prev/next/today header
controls responded — the grid cells were never clickable.
- Pill/drawer view switches now build a visit history instead of
collapsing to the default view. Back retraces the views you moved
through (a not-yet-visited view is pushed; revisiting one collapses the
loop back to it), down to the default, then exits. Widget launches
still reset to their own view context.
Refs #1, #2 (reported by @devinside).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace CalendarHost's single global view slot with a top-level view
back stack rooted at a user-configurable default view. A lateral move
(pill/drawer/widget) replaces the non-home top; a date tap drills the
day view on top; a base-level BackHandler pops one level until only the
home view remains, then the system exits.
Widgets now carry their source view (EXTRA_SOURCE_VIEW) so a launch
roots the stack in that widget's view: backing out of a day/event opened
from the agenda widget returns to Agenda, and from the month widget to
Month, instead of always landing on Week. Reminder taps keep the
separate detail-key channel and leave the base view untouched.
Add a Default view setting (Settings -> Appearance) backing the stack's
home view; defaults to Week so existing users see no change.
Resolves the two Codeberg reports from @devinside:
- #1 [FR] Option to set default view
- #2 [Bug] Widget UX improvement
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump versionName to 2.8.0 (versionCode 20800) — the merge of this to main
is what cuts the release. Move the accumulated 2.8.0 work out of
[Unreleased] into a dated CHANGELOG section and regenerate the F-Droid
per-version changelog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The search ViewModel is activity-scoped and outlives the overlay, so a reopened
search showed the previous query/results — reset the query when the screen
re-enters (peeking a result keeps it, as the screen stays composed under the
detail). Add imePadding so the idle/empty message re-centres in the area above
the keyboard instead of staying centred on the full page behind it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A recurring master's DTSTART is the series start, which for long-running series
reads as an old date and sorts into the past. For recurring hits (non-empty
RRULE/RDATE), resolve the occurrence nearest to now via the Instances provider —
the soonest upcoming within ~2 years, else the most recent past — and display
and sort by that. Falls back to the series start when no occurrence lies in the
window.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A magnifier in each calendar screen's top bar opens a search overlay: type a
query and matching events (title, location or description) appear, nearest-to-
today first; tapping a result opens its detail.
- Data: query the Events table directly with a LIKE selection on title /
description / location (wildcards escaped), so search is unbounded in time and
filtered provider-side. New SearchProjection + toSearchResult mapper reuse the
.ics export's DURATION handling for recurring masters. Hidden calendars are
filtered out, mirroring instances().
- SearchViewModel debounces the query (250 ms), needs >= 2 chars, and orders
results upcoming-ascending then past-descending.
- SearchScreen: autofocused inline field in the top bar, GroupedRow results
reusing the agenda row style, idle/empty states. Hosted as a CalendarHost
overlay below detail/edit so a tapped result's detail draws on top.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lintDebug aborts on the deprecated startActivityAndCollapse(Intent) overload in
the pre-34 branch, even though that overload is the only one available below
UpsideDownCake and is reached only there. Suppress the lint issue (and the
compiler deprecation) at the function level since the call is intentional and
version-gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The create form and Settings/calendar-manager are sibling overlays in one Box,
with Settings drawn above the form. An external 'new event' request (QS tile,
launcher shortcut, widget) set the create state correctly but rendered the form
underneath an open Settings, forcing the user to back out first.
Dismiss the covering overlays (Settings, calendar manager, detail/edit, import)
when handling a Create or OpenDate nav request, so the requested destination is
revealed on top. Fixes the same latent bug for the shortcut and widget.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A stateless TileService that opens the create-event form on today — the same
action as the launcher 'New event' shortcut and the agenda widget's +. Reuses
MainActivity.openCreateIntent; wrapped in unlockAndRun and using the API 34+
startActivityAndCollapse(PendingIntent) form (deprecated Intent form below 34).
No new permission (BIND_QUICK_SETTINGS_TILE is system-side).
Discoverability: Settings → New event form gains an 'Add Quick Settings tile'
row that fires StatusBarManager.requestAddTileService (API 33+); on older
versions the tile is still addable manually from the system QS editor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A thin primary-coloured indicator (leading dot + line) marks the current
time across today's column in the day and week timelines, positioned on the
same HOUR_HEIGHT scale as the event blocks so it lines up with the grid.
Shared ui/common/NowLine.kt ticks once a minute, re-aligning to each minute
boundary to avoid drift, and only mounts on today's column so a single
coroutine runs. Renders nothing when today isn't in view.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reuse the attendee contact-picker mechanism for the location field: a
Contacts button beside the location input opens the system picker scoped
to postal-address rows, and the chosen contact's formatted address is
dropped into the field (multi-line addresses collapsed to one line).
Same no-permission guarantee as the guest picker — ACTION_PICK grants
temporary read access, so no READ_CONTACTS is required.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the name-on-manual-add gap. The add-guest row gains a "from
contacts" button that launches the system contact picker (ACTION_PICK on
ContactsContract Email URI); the picked email row is queried for
Email.ADDRESS + DISPLAY_NAME, so a chosen guest gets both email and name
in one tap. No READ_CONTACTS — the result Intent grants temporary read
access to just that row, so Calendula stays no-permission/no-network. The
inline email field remains the quick email-only path. Mark the roadmap
item shipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mark the attendee-editing write side shipped (b0f34ff) and record the
plan to close the name-on-manual-add gap: a "from contacts" entry via
ACTION_PICK on the contacts Email URI, querying the picked row for
Email.ADDRESS + DISPLAY_NAME — no READ_CONTACTS (the result Intent grants
temporary read access), same no-permission mechanism as the location
address picker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Attendees were read-only (shown on the detail screen since v0.6) but the
form couldn't write them — the last read-only gap in the event model.
Add a Guests section to the create/edit form:
- Inline grouped list: each guest is a tonal card (avatar, name/email, a
tappable Required/Optional role chip, remove); the trailing card is an
inline email field — type an address, press Done, it commits. No dialog,
matching the app's inline-field input idiom. Manual adds are email-only
(names come from sync); the InlineTextField gains IME-action support.
- Reminders restyled to the same grouped-list pattern (shared
GroupedItemCard / AddActionCard), replacing the single-card blob.
Persistence (CalendarDataSource): new guests are written as plain
RELATIONSHIP_ATTENDEE / STATUS_INVITED rows — no fabricated organizer.
On edit, a dirty-checked reconcileAttendees diffs by email: drops removed
guests, inserts new ones, updates only the required/optional flag on kept
rows (preserving response status). Organizer, resources and no-email rows
are never touched. toEditForm carries only editable guests, so attendees
now ride in the edit snapshot and an external guest change trips the
conflict check.
Per the settled invitation decision: Calendula has no INTERNET and never
sends an invitation — it only writes the rows; the backend decides
delivery. The section shows honest, calendar-aware copy ("your account
may email guests when it syncs" on synced calendars, "no one is notified"
on local).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decision (owner, 2026-06-22): attendee editing is record-only on any
writable calendar. Calendula has no INTERNET and never sends an
invitation itself — it only writes Attendees rows. Notification is
decided downstream (local: no one; CalDAV: server iMIP; Google: Google).
Mandatory backend-aware copy makes this honest. No fabricated ORGANIZER;
the optional "send .ics via email app" delegate is deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the planning docs up to date with reality from the changelog and
recent merges:
- Mark the .ics engine (export + import) shipped in v2.7.0 and local-
calendar backup done; clear the stale "in progress" tags.
- Note snooze/dismiss merged into release/v2.8.0 alongside Codeberg
crash reports.
- Record drag-and-drop rescheduling as consciously rejected.
- Promote attendee editing out of the gated bucket into Tier 5 #12
(read side already shipped in v0.6; only the write side is missing),
with the sync-adapter invitation caveat to resolve first.
- Refresh STATE.md (was stuck at v2.4) through v2.7.5 and the 2.8.0
integration branch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The attendee row only used the email as a fallback when the name was
blank, so a guest with both a name and an email never showed the email.
Add it as a supporting line (bodySmall / onSurfaceVariant) beneath the
name, kept off rows whose headline already falls back to the email.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Now that a public, writable issue tracker exists (the Codeberg mirror at
codeberg.org/jlmakiola/calendula, which the Gitea issue tab links to),
revert the email hand-off (6150ce6) back to the in-app issue-creation flow:
the crash path opens a prefilled issues/new page (with clipboard copy as the
long-report fallback) and the manual "Report a problem" path opens the issue
template chooser.
The email pivot existed only because the personal Gitea has no public issue
creation (reporters hit a login wall). Codeberg lets anyone register and
file, so that reason is gone. Still no INTERNET permission — the user submits
via the browser themselves. URLs point at Codeberg directly so the prefilled
title/body survive (a Gitea external-tracker redirect wouldn't carry query
params).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Filing a Gitea issue requires an account on the instance, so anonymous
reporters hit a login wall — Gitea has no anonymous issue creation. Switch
both the crash-report and manual problem-report paths to compose a
pre-addressed email via ACTION_SENDTO (mailto:), which needs no account and
preserves the existing no-INTERNET, user-sends-it-themselves model. The full
report rides in EXTRA_TEXT, so the old URL-length cap and clipboard-paste
fallback are gone (clipboard copy stays as a safety net).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reproducible-release CI guard only asserted `vcsInfo { include = false }`.
F-Droid review surfaced two more invariants that, if they regress, silently
stall official publishing (fails safe — stuck on an old version):
- no foojay toolchain resolver in any Gradle script (the offline source
scanner rejects org.gradle.toolchains.foojay-resolver — it can fetch a JDK
over the network), and
- `dependenciesInfo { includeInApk = false }` (else AGP embeds a "Dependency
metadata" block, id 0x504b4453, in the APK Signing Block, which the binary
scanner rejects as an extra signing block).
The script now checks all three, accumulates failures (reports every broken
invariant in one run, not just the first), and exits non-zero if any fails.
Verified positive + one negative per invariant + all-three-broken.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "Snooze" and "Dismiss" action buttons to reminder notifications.
The app is otherwise pure provider-broadcast (the Etar model): the calendar
provider fires EVENT_REMINDER and we post the notification, then mark the
CalendarAlerts row fired. A snoozed reminder has no provider backing — its
row is already fired — so snooze self-schedules an exact alarm to re-show
the same notification, while primary delivery is left unchanged.
- ReminderActionReceiver (not exported): SNOOZE cancels + schedules a
re-show, DISMISS cancels, SHOW (the alarm) re-posts so it can be snoozed
or dismissed again.
- ReminderSnoozeScheduler: setExactAndAllowWhileIdle, with an inexact
allow-while-idle fallback if exact alarms are revoked (API 31-32).
- ReminderNotifier: two actions + cancel().
- snoozeMinutes pref (default 10) in Settings -> Notifications, OptionPicker
presets 5/10/15/30/60.
- Manifest: USE_EXACT_ALARM + SCHEDULE_EXACT_ALARM (maxSdk 32) + receiver.
- New ic_notification_snooze/_dismiss drawables, duration plurals, en+de
strings, snooze-pref tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F-Droid's binary scanner (the fdroiddata `check apk` job) rejects any
"extra signing block". Our release APK carried AGP's dependency-metadata
block (id 0x504b4453, ~8 KB) in the APK Signing Block. Disable it with
`dependenciesInfo { includeInApk = false; includeInBundle = false }`.
The block lives in the signing block, not the zip entries, so removing it
changes no build output — v2.7.5 is functionally identical to 2.7.4 and
still reproduces byte-for-byte. Verified locally: a releaseTest build now
carries only the v2 signature + verity padding (no 0x504b4453 block).
This was the last F-Droid blocker: v2.7.5 now clears vcsInfo (since 2.7.3),
foojay (since 2.7.4) and the dependency-metadata block. Bumps versionName
to 2.7.5 and retargets the official recipe draft at v2.7.5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F-Droid's official build scanner rejects the foojay-resolver-convention
plugin because it can fetch a JDK over the network during the offline,
reproducible build. The plugin was inert here (no toolchain block ever
invoked it), so removing it leaves the app functionally identical to
2.7.3 and still reproducible byte-for-byte from source.
Bumps versionName to 2.7.4 (the release trigger) and retargets the
official fdroiddata recipe draft at v2.7.4, plus two recipe corrections
the fdroiddata CI caught on the v2.7.3 submission (MR !40967):
- Categories: 'Time' (retired in the current taxonomy) -> 'Calendar & Agenda'
- AutoUpdateMode: 'Version v%v' (invalid per schema) -> 'Version'
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v2.7.3 is the first release built with `vcsInfo { include = false }` and is
verified reproducible: a clean from-source build matched the published
calendula_v2.7.3.apk byte-for-byte across all 1382 zip entries (only the
signature block differs, which F-Droid copies).
- Point the fdroiddata recipe's Builds entry + CurrentVersion at v2.7.3
(was the 2.7.2 placeholder, which lacks the fix and would fail verification).
- UpdateCheckMode: Tags ^v[0-9.]+$ — after this one-time submission F-Droid
auto-tracks new release tags and adds build entries itself; no manual recipe
edits per release.
- Add scripts/check_reproducible_release.sh + an always-on CI step asserting the
release build keeps VCS-info disabled, so reproducibility can't silently
regress and quietly stop official-repo publishing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Home-screen widgets were stuck on the Glance loading layout (a blank spinner)
in the minified release build — but worked in debug. Root cause: Glance renders
every widget through a WorkManager worker (androidx.glance.session.SessionWorker),
and WorkManager instantiates the InputMerger reflectively from the fully-qualified
class name persisted in the WorkSpec (Class.newInstance, no-arg ctor). Under R8
full mode (AGP 9 default) the unused no-arg constructor of
androidx.work.OverwritingInputMerger was stripped, so WorkManager threw
"OverwritingInputMerger has no zero argument constructor", the SessionWorker
never ran, and provideContent never executed — leaving the widget on its
initial loading layout forever.
Same R8-reflection family as the v2.7.0 Room keep-rule fix. Keep the name +
constructor of every androidx.work.InputMerger.
Verified on-device with the releaseTest build (R8-minified): the agenda widget,
which showed only a spinner before, now renders its content; the WM-InputMerger
InstantiationException is gone. Closes#18. Cuts v2.7.3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scope step shallow-fetched the base (`--depth=1`) and ran
`git diff origin/<base>...HEAD`. The three-dot diff needs the merge-base,
which a depth-1 tip doesn't contain once a branch has forked a few commits
back — git aborts with "no merge base" (exit 128) and the whole CI job
fails before lint/test/build ever run.
Fetch the base fully, resolve the merge-base explicitly, and diff from it.
If no common ancestor is found, default to code=true so the build still
runs rather than being wrongly skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make fastlane/metadata/android/ the single source of truth for store
listing metadata, consumed directly by the official F-Droid repo and
transformed into the self-hosted repo's localized layout at release time.
- Move fdroid-metadata/<appid>/<locale>/ -> fastlane/metadata/android/<locale>/
(git-tracked renames: summary->short_description, description->full_description,
+ title.txt, images/icon.png, images/phoneScreenshots/); keep the app-level
.yml control file for the self-hosted `fdroid update`.
- Add scripts/fastlane_to_fdroid_localized.sh (fastlane -> F-Droid localized,
incl. changelogs) and scripts/sync_changelog_to_fastlane.sh (CHANGELOG.md ->
fastlane changelog); verified byte-identical to the previous metadata.
- release.yaml: build self-hosted metadata from the fastlane tree and sync the
per-version changelog before the transform (one changelog source for both
channels).
- Disable AGP VCS-info embedding on release builds (vcsInfo { include = false })
so builds reproduce byte-for-byte vs the distributed APK — the only file that
otherwise differed (META-INF/version-control-info.textproto). Effective from
the next release.
- Add docs/fdroid-official/ (draft fdroiddata recipe: reproducible build +
AllowedAPKSigningKeys + Binaries + notes).
- Repoint README screenshots/icon, update docs/README + RELEASING, and skip the
Android build on fastlane-only changes (ci.yaml).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks the pipeline so a change is built once and a release is driven by the
merge, not a manual tag push.
- ci.yaml now runs on pull_request (one gate per PR) instead of every branch
push, so there's no CI-on-push + CI-on-merge double run. A single `ci` job
with a docs-only fast-path keeps the required "CI" check always reporting
(docs/metadata-only PRs skip the Android build but still go green).
- release.yaml triggers on push to main. A cheap `detect` job reads versionName
from build.gradle; only when no tag for it exists does the `release` job run:
tests on the merged commit, build + sign, publish to F-Droid, then create the
vX.Y.Z tag + Gitea release via the API (target_commitish = the merged sha).
The tag is now an OUTPUT of a successful release, not its trigger — a failure
before publish leaves no tag, so re-running safely retries. No more separate
tag-triggered run or duplicate ci job.
- The committed versionName/versionCode are now the source of truth (pipeline
pins versionCode from versionName); updated the build.gradle comment.
- translations.yaml switched to pull_request (same path filter).
- docs/RELEASING.md: release-by-merge flow, no manual git tag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a mandatory pre-tag step to the release process: build the R8-shrunk
release candidate and smoke-test it on a real device, including a first-run /
permission-not-granted state. The v2.7.0 launch crash (calendar observer
registered before the permission gate) reached users because it only manifests
in the minified release build on a device without the permission already
granted — the debug build and an already-permissioned phone both hid it.
- New `releaseTest` build type: same R8 shrinking + obfuscation as `release`,
but debug-signed with a `.releasetest` applicationId suffix so it installs
alongside the production and debug apps. Never published; CI only ever builds
the real `release` variant from the tag.
- scripts/verify-release.sh: builds + installs `releaseTest` and resets it to a
first-run state, with an on-device checklist.
- docs/RELEASING.md: formalize the release/vX.Y.Z branch flow and the on-device
verification gate before tagging.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v2.7.1 is taken by the launch-crash fix (calendar observer registered before
the permission gate). Bump this held crash-reporting release to 2.7.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The repository registers a ContentObserver on the calendar provider eagerly
in its init block, and an activity-scoped SettingsViewModel (which drives the
theme) injects that repository — so the @Singleton is constructed at launch,
above RootScreen's permission gate. On newer Android, registering an observer
on a provider you lack permission for throws SecurityException instead of
silently no-op'ing, so the app crashed instantly on every launch whenever
calendar access wasn't granted (fresh install or revoked permission), before
the permission screen could ever appear.
Guard the registration behind a calendar-permission check and re-attach the
observer lazily on the first calendars()/instances() read, which runs once the
gate opens and screens subscribe. Access to the observer collections is now
synchronized since registration can happen on the main thread (repo init) or
the IO dispatcher (query re-attach).
Verified on-device: permission-denied launch shows the permission screen
instead of crashing; granting it proceeds to the calendar with live updates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
renovatebot/github-action is a Node wrapper that shells out to
`docker run ghcr.io/renovatebot/renovate`, requiring a Docker CLI + socket
inside the job. The Gitea runner executes the job in a plain node:22 container
with neither, so it died on "Unable to locate executable file: docker".
Run the renovate image as the job container and invoke `renovate` directly —
drops the docker-in-docker requirement. Full tag pinned; Renovate's
github-actions manager keeps container.image bumped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
renovatebot/github-action ships only full semver tags; @v40 was an
invalid ref and the dispatched run failed to resolve it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump committed versionCode/versionName to 2.7.1 (20701) and move the
crash-reporting entry under a 2.7.1 CHANGELOG heading. The tag remains the
source of truth; CI derives the published version from it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit of our own throw sites, since exception messages land verbatim in
the stack trace a crash report carries. Redacts the three that could hold
user content; the rest only carry numeric ids/timestamps (metadata, kept
for debugging):
- create-local-calendar: drop the user-typed calendar name.
- toContentValues unsupported-type: log the value's type, never the value
(a cell can be an event title/description/location).
- ics export open-failure: log only the Uri scheme, not the full Uri
(which can embed the user's chosen filename).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
renovate.json5 (config:recommended + semantic commits, no automerge,
dependency dashboard; material3 stays on its 1.5-alpha pin in an
isolated PR; test deps grouped; github-actions manager watches
.gitea/workflows). Cadence owned by .gitea/workflows/renovate.yml
(Mondays 05:00 UTC + manual dispatch), self-hosted via
renovatebot/github-action, scoped to makiolaj/calendula.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture uncaught exceptions on-device and let the user submit them, by
hand, as a Gitea issue — no network access, no auto-upload (the app holds
no INTERNET permission). Closes prod-readiness item 10; the issue
templates also close item 7.
- CrashReporter: uncaught-exception handler installed first in
CalendulaApp.onCreate so startup crashes are caught too. Persists an
allowlist-only report (app/Android/device version, locale, time, stack
trace — nothing else) to filesDir/crash, then chains to the previous
handler so the process still dies normally. Crash-loop detection +
markHealthy reset.
- buildCrashReport is pure/testable; CrashReportBuilderTest asserts the
header is exactly the allowlisted lines (guards against PII creep).
- Surfacing: next-launch dialog showing the full report verbatim (the
privacy backstop) with a dismissed-marker so it doesn't nag; a Settings
"Report a problem" row; and a minimal standalone CrashReportActivity
that MainActivity routes to on a startup crash-loop, kept clear of the
Hilt graph / DataStore theme.
- submitCrashReport copies the report to the clipboard and opens the
prefilled Gitea issues/new URL (long traces fall back to paste).
- .gitea/ISSUE_TEMPLATE: crash_report, bug_report, feature_request.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The minified release build crashed on every launch before any UI:
Unable to get provider androidx.startup.InitializationProvider:
Failed to create an instance of androidx.work.impl.WorkDatabase
The home-screen widgets use Glance, which pulls in WorkManager and its
transitive Room database (room-runtime 2.2.5). Room 2.2.5's bundled keep
rule is `-keep class * extends androidx.room.RoomDatabase` — it keeps the
class but not its constructor. Under R8 full mode (AGP 9) the generated
WorkDatabase_Impl was reduced to a non-instantiable class, so Room's
reflective newInstance() threw InstantiationException at startup.
Add `-keep class * extends androidx.room.RoomDatabase { *; }` so the
generated *_Impl classes keep their constructors. Verified against the
rebuilt release APK: WorkDatabase_Impl is now PUBLIC FINAL with its
<init> present.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes v2.7 Branch 2. Wires the import core into the app:
- Manifest ACTION_VIEW/SEND for text/calendar; MainActivity parses the
incoming Uri (content/file only, so calendula:// deep-links don't match)
and routes it through RootScreen → CalendarHost like the other one-shot
intents.
- ImportViewModel reads + parses the file and routes by count: one event →
the prefilled create form for review (EventEditViewModel.openImported,
which freezes the reminder default so the file's reminders win); many →
ImportScreen with a writable-calendar picker, then a bulk import (UID
dedup) and a result summary.
- ImportScreen also surfaces parser warnings (skipped recurrence overrides,
ignored attendees, unknown-timezone fallback). Strings EN+DE.
Package is ui.imports (not ui.import — Java keyword). lint + test +
assembleDebug green. No v2.7 tag until on-device review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v2.7 Branch 2 (core, no UI yet). The read side of the .ics engine:
- domain/ics: IcsParser (inverse of IcsWriter) — unfold/unescape/param
parsing, VALUE=DATE / UTC-Z / TZID date handling resolved against the OS
tz db, VEVENT walk → ParsedIcsEvent + typed warnings. Liberal-in/
strict-out: a malformed VEVENT is skipped, RECURRENCE-ID overrides /
attendees / unresolved TZIDs are reported, not silently dropped.
- Promoted parseRfc2445DurationMillis into domain/ics (shared by writer-
side mapper and parser); IcsDuration + test.
- Datasource existingUids()/insertImportedEvent(); repository
importEvents() with UID dedup (skip known UIDs → idempotent restore) →
IcsImportSummary. IcsImporter reads a Uri's text.
- ParsedIcsEvent.toEventForm() for the single-event "open into the create
form" path.
Parser round-trips against IcsWriter; dedup + form-adapter unit-tested.
Intent filter, routing and import UI land in the next commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All-day events live at UTC midnights with an exclusive end, but coversDay
sliced each day in the device timezone. East of UTC the exclusive end
landed a few hours into the next local day, so a one-day all-day event
(e.g. a birthday) rendered on two days in the day/week/month views — while
the detail and edit screens, which work in UTC, showed it correctly.
Compare all-day coverage in UTC and step the exclusive end back to the
last covered day, mirroring the detail/edit views.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v2.7 Branch 1 of 2: .ics export — single-event share + whole-calendar backup of local calendars. Import (feat/ics-import) lands next in the same release.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Branch 1 of 2 for v2.7 (the .ics topic). Adds the write side of a
hand-rolled RFC 5545 engine (zero deps, stays on kotlinx-datetime):
- domain/ics: IcsText (escape + 75-octet folding), IcsEvent model,
IcsWriter.writeCalendar. Timezone rule: all-day VALUE=DATE, one-off
timed UTC Z, recurring timed TZID-labelled from EVENT_TIMEZONE (no
VTIMEZONE — import resolves TZID against the OS tz db).
- Single-event share from the detail screen (FileProvider + ACTION_SEND).
- Whole-calendar backup of the writable local calendars to a SAF file
(Settings -> Calendars -> Export as .ics), one combined VCALENDAR.
- insertEvent now writes Events.UID_2445; legacy rows fall back to a
stable synthesised UID at export time so a later restore won't dupe.
- EXDATE / RECURRENCE-ID overrides are deliberately skipped this pass
(documented v1 limit; import will skip them too).
Engine + mapper unit-tested. Import (Branch 2, feat/ics-import) ships in
the same v2.7 release; no tag until both land + on-device review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-app language picker silently did nothing: AppCompatDelegate.set
ApplicationLocales only syncs to the system from an AppCompatActivity, but
MainActivity was a plain ComponentActivity (with a platform theme). Switch
MainActivity to AppCompatActivity and base Theme.Calendula on
Theme.AppCompat.DayNight.NoActionBar.
Changing the locale recreates the activity; set android:windowBackground to a
DayNight colour matching the Compose background (light #FBFCFE / dark #101316)
so the recreation no longer flashes a contrasting backdrop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All-day events live at UTC midnight, so a raw "1 day before" reminder
fires at an off hour (02:00 local in CEST) rather than the morning. Add a
global "all-day reminder time" setting (default 09:00) and encode it into
the provider MINUTES offset so the reminder lands at the chosen wall-clock
time the day before instead.
- AllDayReminderEncoding: pure to/from provider-minutes helpers, keeping
the form/UI/diff in whole-day "semantic" minutes and converting only at
the Reminders read/write boundary (insertEvent, reconcileReminders,
EventDetailMapper). Covers DST, negative offsets, and pre-existing rows.
- SettingsPrefs.allDayReminderTimeMinutes (default 540) threaded from the
repository into the data-source write paths.
- Settings: a time-picker row, plus a shared TimePickerAlert lifted from
the event editor.
- Fix the time picker's 12/24-hour detection: honour an explicit system
override, else fall back to the device locale rather than the app's
per-app language, so it matches the rest of the device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add scripts/check_translations.py and a lightweight Translations workflow
that runs it (no Android SDK needed) so Weblate PRs get fast feedback. The
script fails on stale keys (present in a translation but not the base) and on
translating translatable="false" entries; missing keys are reported as
coverage only.
Downgrade lint's MissingTranslation to informational: partial community
translations are expected and fall back to the English base at runtime.
Stale/extra keys (ExtraTranslation) remain fatal in lintDebug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the supported-language list a single source of truth so community
translations show up with no code change: add res/xml/locales_config.xml
(en, de) and reference it via android:localeConfig, which also surfaces the
per-app language entry in Android 13+ system settings.
Rewrite AppLanguage to parse locales_config.xml for the supported BCP-47
tags and expose currentTag/apply/displayName (autonyms), dropping the
hardcoded LanguagePref enum; the Settings picker is now built from that list.
Remove the now-unused settings_language_german/english strings.
Adding a language is now: drop in values-<tag>/strings.xml and add one
<locale> line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundles the unreleased Tier 2/3 work into one release:
- Home-screen widgets (Glance): an "Upcoming" agenda widget and a month-grid
widget, both reusing the in-app grouping/layout (groupAgendaDays,
layoutMonthWeeks) via a Hilt WidgetEntryPoint, honouring hidden-calendar
filters and refreshing on PROVIDER_CHANGED / date rollover.
- App shortcut: launcher long-press "New event", routed through the shared
WidgetNavRequest.Create channel into the create-event form.
- Agenda view and jump-to-date (already merged via #3/#4) are documented here
as part of the shipped version.
Bumps versionCode 20500 / versionName 2.5.0, moves the CHANGELOG Unreleased
section under [2.5.0], updates ROADMAP/STATE, and adds EN+DE strings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fourth top-level view, alongside Month/Week/Day. A forward-looking
LazyColumn of upcoming events grouped under sticky day headers, reusing
the v2.3 grouped-list language (GroupedRow cards, color-rail leading).
- AgendaViewModel loads a 60-day forward window from the anchor day
(today by default; goToToday/goToDate drive the FAB + drawer jump),
groups instances by local day (ongoing/multi-day clamped to the
anchor), sorts all-day-first then by start.
- AgendaScreen: same drawer + scaffold + view-switcher + FAB shell as
Day; sticky "Today · …"/"Tomorrow · …" headers, event rows with
time·location, plus empty/failure/loading states.
- Wired into CalendarView (ViewAgenda icon), IMPLEMENTED_VIEWS, and
CalendarHost; strings added (EN + DE).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Jump to date" row to the drawer (under the View switcher) that
opens an M3 date picker and navigates the active view to the chosen day,
sliding in from the correct side. Wired across Month/Week/Day, each
seeding the picker with its visible anchor (day / week-start / 1st-of-month).
Extract the form's private date-picker into a shared
ui/common/CalendarDatePickerDialog so the event form and the drawer share
one picker; add goToDate() to the Month and Week view models.
Reprioritises the roadmap: jump-to-date is now next; duplicate-event drops
to the bottom as low-importance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Optional per-event color in the event form. The read/render path already
resolved EVENT_COLOR with a calendar fallback; this adds the write side and
the picker.
- Palette-backed calendars (Google, some CalDAV) pick from the account's
Colors (TYPE_EVENT) and write EVENT_COLOR_KEY, so the color round-trips
through sync; local calendars write a raw EVENT_COLOR from the shared
CALENDAR_COLOR_PALETTE. Never writes a raw color to a palette calendar.
- Swatch row + palette extracted to ui/common/ColorSwatchRow.kt (shared with
the calendar editor). Switching calendars resets the choice (keys are
account-scoped); a "Reset" action returns to the calendar color.
- New "Allow colors on unsupported calendars" setting (off by default)
extends the raw path to no-palette synced calendars, with an honest
"may not survive sync" warning on the picker and in Settings.
- Color flows through insert / dirty-checked update / occurrence-exception;
mapper, form, and repository tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app
and extracted to ui/common/GroupedList.kt: CollapsingScaffold (a LargeTopAppBar
whose large title collapses into the bar on scroll) and GroupedRow
(Position-based corner grouping so a run of rows reads as one rounded card, with
press-animated corners and selected/minHeight knobs).
Settings: restructured into a category hub (About card on top, version mark at
the foot) with sliding sub-pages for Appearance, the new-event form and
Notifications. Theme, week-start and language pickers migrated from DropdownMenu
to OptionCard dialogs; token-based icon chips. New ic_gitea.xml (Simple Icons,
verbatim path) for the About "Source" button; en+de strings.
Calendar manager: same collapsing scaffold + grouped rows; shared
CalendarColorChip (neutral chip with a pastelised calendar glyph) replaces the
bright colour swatch.
Navigation drawer: branded header, grouped View switcher (active view
highlighted via secondaryContainer), filter list restyled to grouped rows with a
trailing checkbox; the whole drawer now scrolls as one.
Cards use surfaceContainerHigh for readable contrast against surface. Version
bumped to 2.3.0 / 20300. UI-only; unit tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Day/week: tap an empty slot to open the create form prefilled with that
day and the tapped hour (snapped to the hour, 1 h long). Threaded a start
time through CalendarHost → EventEditScreen → openNew; the FAB keeps its
default.
Local calendars: a full-screen editor from Settings → Calendars to
create/rename/recolor/delete device-only calendars (ACCOUNT_TYPE_LOCAL,
sync-adapter insert) with name, pastel-previewed colour, and a description
(stored in CAL_SYNC1). Synced calendars are listed read-only grouped by
account, each with a "manage in source app" deep-link resolved from the
account's own authenticator (DAVx5/ICSx5/…), plus an add-account shortcut;
a <queries> block makes the source apps launchable. Extracted a shared
InlineTextField into ui.common so the event form and calendar editor share
one borderless input style.
Tests: repository delegation + write-failure, mapper isLocal/description,
fake data source extended. Version bumped to 2.2.0 / 20200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the per-day dot summary with an event-rich grid. The ViewModel now
splits the grid into week rows and, per row, resolves all-day/multi-day
events into spanning bars (reusing the week view's layoutAllDay lane math)
and single-day timed events into per-day pills.
The grid renders as an overlay: each day gets a rounded surfaceContainer
background (matching the week/day views), spanning bars draw on top so a
multi-day event is one connected bar bridging the cells it covers, and
single-day pills fill the lane slots no bar occupies on that specific day
(top-most first) so a bar-free day isn't pushed down. Up to three rows
show per day, then a "+N" dot row. Today is a filled circle on its number;
neighbour-month days are dimmed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The slide-out panel gains a "View" section mirroring the top-bar switcher
pill: three NavigationDrawerItems (Month/Week/Day) with the current view
highlighted; tapping one selects that view and closes the drawer. The pill
stays as-is for quick cycling.
Centralise each view's label + icon as labelRes/icon extensions on
CalendarView so the pill and the drawer share one mapping. The drawer's
"Today" jump is dropped — the top-bar Today action and error-state retry
still cover it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1.3 Versioning: the git tag is already the de-facto single source of truth
(every published versionCode uses MAJOR*10000+MINOR*100+PATCH; committed 13
was a stale outlier). Align the committed default to 20000 and document the
scheme in a comment + docs/RELEASING.md.
P1.4 F-Droid changelogs: a tag-only step extracts the tag's CHANGELOG section
into metadata/.../en-US/changelogs/<versionCode>.txt so clients show a
per-version "What's New". Also upload metadata/ (non-secret, never web-served)
alongside repo/ so changelog history survives across releases.
P1.5 R8 mapping: attach mapping-<version>.txt.gz to the Gitea release
(best-effort, continue-on-error) so user crash stacktraces stay
deobfuscatable. The gitea-release notes step is now an upsert (PATCH if the
release already exists) so it composes with the mapping step creating the
release first.
P1.6 docs/RELEASING.md: release ritual, versioning scheme, secrets inventory,
key custody/recovery, manual re-sign path, F-Droid repo details.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The event form's state pipeline ran .flowOn(io) over the whole combine,
including the _form round-trip every keystroke depends on. That async hop
handed BasicTextField a lagging value while typing, so Compose kept
correcting the cursor to the stale position.
Scope flowOn(io) to just the calendar/prefs/settings reads and collect the
form -> state -> UI path on the main dispatcher, so keystrokes round-trip
synchronously and the cursor stays put.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The release job assumed the ref is a version tag (Set version from git tag →
versionCode). A manual workflow_dispatch from a branch yielded versionCode 0
and Gradle aborted assembleRelease before the F-Droid steps ran.
Gate the tag-only steps (version, app keystore, assembleRelease, copy APK)
on refs/tags/*. On a manual dispatch the job now skips the APK build and just
re-signs the existing index with the configured repo key and re-uploads —
exactly what a repo-key rotation or recovery needs, no new release required.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The F-Droid repo signing key (keystore.p12) and its config.yml — including
the keystore passwords in cleartext — were publicly downloadable at
apps.dev.jeanlucmakiola.de/dev/fdroid/ because the release workflow uploaded
the entire fdroid/ working dir into the web-served path. The webserver has
since been locked down to repo/ only; this rotates the now-compromised key
and removes the root cause.
- release.yaml: restore the repo key + config from new CI secrets
(FDROID_KEYSTORE_BASE64, FDROID_CONFIG_BASE64) instead of the box; upload
ONLY repo/ so the key never re-enters the served tree.
- release.yaml: fail loudly when the repo key secrets are unset, replacing
`fdroid update --create-key`, which silently minted a NEW repo key on a
wiped server and would have broken every user's pinned fingerprint.
- README: publish the new repo fingerprint (C2C0…3425). Existing users must
remove and re-add the repo.
- .gitignore: ignore *.p12 and the whole /fdroid/ working dir.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
README gains a real install path: add the self-hosted repo
(apps.dev.jeanlucmakiola.de/dev/fdroid/repo, fingerprint inline and as an
add-repo link), search, install. Verified live against the repo index.
Roadmap gains the approved daily-driver idea backlog (unscheduled): slot-tap
create, drag & drop rescheduling, agenda view, pinch-zoom, reminder
snooze/dismiss + default reminder, duplicate event, per-event color,
.ics share/receive, app shortcuts, jump-to-date — plus the consciously
rejected list (network-dependent features, NL quick entry).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documentation pass after the 2.0 milestone:
- docs/ARCHITECTURE.md — principles (provider as single source of truth,
observer-driven UI, JVM-first tests, no network), layer + reminder
mermaid diagrams, navigation (overlay/held-key, no nav lib), and the
provider lessons (recurring-write invariants, conflict snapshots)
- docs/README.md — map of what documentation lives where, incl. the
convention that superpowers/ plans are historical artifacts while
.planning/ stays current
- README.md — showcase layout (centered header, badges, screenshot
gallery from the fastlane assets, grouped features, install/build/
architecture/roadmap sections); renders on Gitea
- .planning/{PROJECT,REQUIREMENTS,STATE}.md unstaled: read-only-V1 talk
removed, V1/V2 checklists marked shipped, state points at v3 + the
Locations & People go/no-go
release.yaml gains a gitea-release job: on every tag push it extracts the
tag's CHANGELOG section and creates a Gitea release with it as the notes.
No APK assets — distribution stays with the F-Droid repo. Idempotent
(skips an existing release), gated on the test job only so notes appear
even when the F-Droid upload hiccups.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Captured from discussion, deliberately undetailed: permission-free contact
address picker, Photon-based address autocomplete (would need INTERNET —
explicit go/no-go on the no-network promise before any work), inline
contact suggestions, attendee editing as its own future milestone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version bumped to 2.0.0 / 13. No code changes beyond the version — 2.0.0
closes out Milestone 2 (write support, v1.1 through v2.0): the final slice
is the save-conflict dialog (external change → overwrite/discard, external
delete → informational close), plus the store refresh: descriptions and
README describe write support and reminders, and fastlane screenshots
(DE+EN, six each) ship for F-Droid. CHANGELOG [2.0.0] carries the details.
Quick-add was cut from scope (the prefilled form covers it); calendar
switching while editing moved to the v3 backlog. Both documented in the
roadmap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No locking (plan 03, decision 5): openForEdit keeps an EditSnapshot — the
prefilled form plus the raw Events-row times, which the form itself can't
see (it derives its times from the tapped occurrence, so an externally
moved event would otherwise stay invisible). Right before writing,
performSave re-reads the event and compares snapshots: a mismatch parks
the save in SaveUiState.AwaitingConflict carrying the already-chosen
recurring scope, and the dialog offers overwrite / discard / cancel
(OptionCard style). Overwrite still writes only dirty fields, so external
changes to untouched fields survive either way. A deleted event lands in
SaveUiState.Gone — an informational dialog that closes form and detail.
Fields the form can't write (attendees, status, self response, reminder
methods) are excluded from the comparison so sync noise can't fake a
conflict. The load-time zone is pinned in the EditTarget so a device
timezone change mid-edit can't either.
Store metadata: F-Droid descriptions (DE+EN) and the README stop claiming
read-only and now describe write support and reminder delivery. New
fastlane phoneScreenshots (6 per locale: week/month/day/detail/form/
reminder onboarding), captured on-device against demo-only calendars.
Tests: EditSnapshot equality (unchanged event, field change, row-time move
the form can't see, non-writable changes stay quiet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version bumped to 1.4.0 / 12. No code changes beyond the version — 1.4.0 is
the reviewed-and-approved reminder slice: the EVENT_REMINDER receiver posting
due CalendarAlerts on a dedicated channel, tap-to-detail, the one-time
onboarding step requesting POST_NOTIFICATIONS with the duplicate-reminders
warning, and the Settings mirror. CHANGELOG [1.4.0] carries the details.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Calendula now posts event reminders itself (the Etar model): the provider
schedules the alarms and broadcasts EVENT_REMINDER, but a calendar app must
turn them into visible notifications — essential for users whose only
calendar app this is. A manifest-registered, exported receiver (data scheme
content://com.android.calendar) wakes us at reminder time; no foreground
service, no own alarm scheduling.
Delivery path (data/reminders/): EventReminderReceiver (Hilt, goAsync) →
ReminderAlertStore queries CalendarAlerts for STATE_SCHEDULED rows with
ALARM_TIME <= now → ReminderNotifier posts one notification per alert on a
dedicated high-importance channel, then best-effort marks rows FIRED
(needs WRITE_CALENDAR; without it a re-broadcast silently replaces — tag
per alert + setOnlyAlertOnce). Swiped notifications never return: FIRED
rows are never re-queried, so no dismiss-intent machinery. Research
(AOSP CalendarAlarmManager): the provider creates alert rows only for
METHOD_ALERT reminders, so the email-reminder filter happens upstream.
Tapping opens the event's detail screen: MainActivity is singleTop now,
parses eventId/begin/end extras (onCreate + onNewIntent) into Compose
state, and CalendarHost consumes the key exactly like an event tap.
Onboarding gained a one-time second step after the calendar grant (shared
OnboardingScaffold extracted from PermissionScreen): explains delivery,
warns that a second calendar app with notifications on duplicates
reminders, requests POST_NOTIFICATIONS (dialog on API 33+ only; minSdk 29).
"Not now" turns the feature off; reminders default ON. Settings mirrors
the toggle in a new Notifications section with the duplicate hint, and
re-requests the permission when enabling. Strings DE+EN.
Deliberately deferred (roadmap): snooze/dismiss actions, BOOT_COMPLETED /
exact-alarm scheduling, battery-exemption prompts.
Tests: reminderTimeText (all-day UTC-midnight reading, exclusive end day,
midnight-crossing ranges), reminders/onboarding pref round-trips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version bumped to 1.3.0 / 11. No code changes beyond the version — 1.3.0 is
the reviewed-and-approved edit slice: shared form for editing, scope-at-save
for recurring events (this / this and following / all, exception rows and
series splits), three-way recurring delete, simple recurrence picker with
weekly weekday toggles, and the stale-instances split fix. CHANGELOG [1.3.0]
carries the details.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The create form (v1.2) now edits: a pencil on the detail screen (writable
calendars only, contextual WRITE upgrade like delete) opens it prefilled via
EventDetail.toEditForm; populated sections always show, the calendar is
fixed, and a dirty-check writes only changed columns (pristine saves are
no-ops). Saving a dirty recurring event parks in SaveUiState.AwaitingScope
and asks how far the change reaches (Google model): "only this event" =
modified-occurrence exception via CONTENT_EXCEPTION_URI (empty optionals as
explicit NULLs since the provider clones the parent row), "this and all
following" = series split (insert new event first, then truncate), "all
events" = series-row update with the time delta applied to the series
DTSTART. A changed rule drops the exception option. Delete gained the same
middle scope.
Recurrence: EventForm.rrule + SimpleRecurrence (FREQ/INTERVAL/UNTIL/COUNT +
weekly BYDAY with locale-ordered weekday toggles) behind a picker on create
and edit; unrepresentable rules render humanized (shared ui/common
RecurrenceText) and survive verbatim. UNTIL validation flags rules ending
before the event starts.
Provider lessons baked in (verified on-device via adb probes): instance
caches regenerate only from an update's own values, so truncation sends the
full time-column set (truncateSeries) — RRULE-only updates left a stale
duplicate occurrence on the split day; UNTIL is written as the local end of
day in UTC (toRRule(zone), previousLocalDayEndUtcMillis) so UTC+x zones
can't leak an extra day. Reminder edits reconcile against actual provider
rows, keeping untouched rows' methods.
Tests: RecurrenceTest (parse/render/round-trip, truncation), update/exception
mapper paths, repository pass-throughs, prefill + populatedFields, raw-title
mapper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version bumped to 1.2.1 / 10. No code changes beyond the version — 1.2.1 is
the reviewed-and-approved form polish: card design system, optional fields
with settings defaults, reworked reminders, OptionCard dialogs app-wide,
expressive theme on standard springs, direction-aware today jump, IME fix.
CHANGELOG [1.2.1] carries the details.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-v1.2.0 design iteration on the event form, reviewed slice by slice
on-device:
- Form rebuilt on the detail screen's card system: tonal EditCards with
gutter icons (centred on the first row, top-aligned for multiline),
borderless inline fields (placeholders at half opacity), calendar-coloured
title accent, no dividers, bare top bar
- Optional sections (location, description, reminders, availability,
visibility) with per-user defaults in Settings ("New event form" toggles);
hidden ones unfold via a "More fields" picker dialog
- Reminders: stacked rows + full-width borderless add; two-step picker
(one-tap presets, then custom amount + minutes/hours/days/weeks dropdown);
written as METHOD_ALERT Reminders rows. Availability busy/free segmented
toggle; visibility selector with per-level icons
- OptionCard (ui/common) is now the app-wide selection-dialog standard;
calendar picker, visibility, more-fields, reminder presets and the
recurring-delete chooser all use it — radio-row dialogs removed
- MaterialExpressiveTheme with MotionScheme.standard() (expressive bounce
felt overdone); FAB stack + field reveals animate on theme springs;
jump-to-today slides toward today's actual direction
- IME: adjustResize + imePadding so the keyboard never pans the form
- Tests: form-field prefs round-trips, availability/access provider
mappings; DE+EN strings throughout
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version bumped to 1.2.0 / 9. No code changes beyond the version — 1.2.0 is
the create slice: event form, "+" FAB on every view, last-used-calendar
preselect, provider-correct all-day storage. CHANGELOG [1.2.0] carries the
details; ROADMAP/STATE mark slice v1.2 shipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second slice of milestone 2 (write support):
- EventForm domain model + problems() validation (end-before-start,
no-calendar; blank titles and instant events stay legal)
- Full-screen EventEditScreen: title, all-day switch, M3 date/time pickers
(moving the start preserves the duration), calendar picker limited to
writable calendars, location, description. Save validates, requests the
WRITE upgrade contextually, and closes on success
- Calendar preselection: explicit pick > last-used (CalendarPrefs) > first
writable calendar
- insertEvent in the data source; EventWriteMapper (JVM-tested) normalises
all-day events to UTC midnights with exclusive DTEND, timed events to the
device zone
- CalendarFabColumn shared by month/week/day: persistent "+" FAB anchored on
the visible day, jump-to-today pill stacked above it
- Tests: EventForm validation, write-time mapping (incl. DST-safe epoch
check), repository createEvent delegation/error propagation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version bumped to 1.1.0 / 8. No code changes beyond the version — 1.1.0 is
the write-foundation slice: WRITE_CALENDAR, read-only-calendar detection,
and event delete (whole series or single occurrence). CHANGELOG [1.1.0]
carries the details; ROADMAP/STATE mark slice v1.1 shipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First slice of milestone 2 (write support), per the new plan in
docs/superpowers/plans/2026-06-11-03-write-support.md:
- Delete from the event detail screen with confirmation; recurring events
choose "only this event" (cancelled exception via CONTENT_EXCEPTION_URI,
series survives) or "all events in the series" (Events-row delete)
- WRITE_CALENDAR in the manifest; onboarding requests read+write in one
system dialog but only read gates the app — declining write keeps it
usable read-only. v1.0 installs get a contextual write request on their
first delete
- CALENDAR_ACCESS_LEVEL is read into CalendarSource.canModifyContents;
read-only calendars (WebCal, birthdays, …) show no write actions. The
no-op placeholder Edit button is removed until edit ships (v1.3)
- Onboarding copy drops the now-false "read-only" claim (DE+EN)
- Tests: repository delete delegation/error propagation, access-level
mapping; FakeCalendarDataSource grows write ops
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- skip setup-android's default packages (pulled the ~300 MB emulator every run)
- drop unused platforms;android-36 and the dead jq install step
- cache /opt/android-sdk and ~/.gradle (release.yaml had no cache at all)
- drop --no-daemon so lint/test/assemble reuse one warm daemon per job
- Trivy scan only on main (advisory-only; was ~25s tax on every branch push)
- concurrency group cancels superseded runs; drop duplicate pull_request trigger
Companion to the act_runner fix on the CI host: job containers now join the
runner's network so the actions/cache server is reachable (saves previously
failed with reserveCache timeouts, so no cache was ever stored).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version bumped to 1.0.0 / 7. No code changes beyond the version — 1.0.0 is the
accumulated v0.1 → v0.6 work (all V1 screens, full event read, filter, settings,
onboarding polish) declared release-ready. CHANGELOG [1.0.0] summarises the
shipped feature set; ROADMAP/STATE mark V1 complete.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The onboarding screen is the first thing a new user sees; it was a bare
centred title + body + button. Rebuild it as a proper Material 3 Expressive
welcome:
- Branded hero reconstructing the launcher mark (slate squircle + foreground
vector); the denied state adds a lock badge over the corner
- App-name eyebrow, a benefit-led headline, and three trust rows (stays on
device / every calendar together / no tracking) with tonal icon chips
- Full-width filled CTA with a trailing arrow, pinned in a Scaffold bottom bar
clear of the navigation bar; scrollable body for short screens
- "Read-only · no internet permission" footnote — accurate: the app declares
only READ_CALENDAR
- Denied/recovery state reuses the same shell with Open-settings (primary) and
Try-again (text) actions
- 8dp spacing scale, edge-to-edge insets handled via Scaffold
Built with the newly installed material-3 skill's token/component guidance.
Resolves the pre-1.0 polish backlog item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Busy availability value is the default for nearly every event, so a
"Busy" chip on every detail screen was noise. Show the pill only for Free
(the noteworthy case) and move it to the top-right of the title row instead
of the under-title chip strip. That strip now carries only status/access and
hides entirely when there's nothing noteworthy. Drops the now-unused
event_availability_busy string from both locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round out the read-only model so the detail view shows everything
CalendarContract actually stores, ahead of write support.
Data layer:
- New domain types: Reminder, EventStatus, Availability, AccessLevel,
AttendeeRelationship, AttendeeType; EventDetail gains reminders, status,
availability, accessLevel, eventTimezone, selfStatus and Attendee gains
relationship + type (all defaulted so existing callers compile)
- EventDetailProjection reads STATUS / AVAILABILITY / ACCESS_LEVEL /
EVENT_TIMEZONE / SELF_ATTENDEE_STATUS; AttendeeProjection reads
RELATIONSHIP + TYPE; new ReminderProjection queries CalendarContract.Reminders
- Mappers translate each provider integer code, guarding STATUS's null-vs-0
ambiguity (0 == TENTATIVE) so an absent status reads as Confirmed
- Mapper unit tests cover every new column's codes
Detail UI:
- Status / availability / access chips under the title; cancelled also strikes
the title through
- Reminders card with humanised lead times (plurals, DE + EN)
- Foreign-timezone card, shown only for timed events in a non-device zone
- Attendee role badges + the user's own "Your response: …" line
- http(s) URLs in the description are now tappable
URL field cut: CalendarContract exposes no Events.URL column (only the
CUSTOM_APP_URI app deep-link), so URLs are surfaced by linkifying the
description instead. Recorded in ROADMAP/CHANGELOG.
Version bumped to 0.6.0 / 6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan to surface every readable CalendarContract field (reminders, status,
availability, attendee role + self-status, timezone, URL, access level) in
the detail view before write support. Recurrence-override badges and
CATEGORIES/ATTACH stay out (the former folds into v2, the latter is a
provider limitation). Noted only — implementation comes later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The date-picker half of M2 is dropped entirely; the "Today" half already
shipped in v0.5. V1 is now feature-complete and only a polish/QA pass
remains before v1.0.
Updated the living planning docs (ROADMAP, STATE, REQUIREMENTS) and the
design spec; corrected the v0.5.0 CHANGELOG note that promised M2 would
return in v1.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
M3 — calendar filter: the navigation drawer now hosts the calendar list
inline (grouped by account, colour swatch + checkbox per calendar). Hidden
calendars are persisted app-side and filtered centrally in the repository,
so month/week/day re-filter live the moment a checkbox flips. Drawer trimmed
to Today, the calendar filter, and Settings, with leading icons and a clear
title/section type scale; the stubbed jump-to-date entry (M2) was removed.
M4 — settings: full-screen destination with appearance (theme System/Light/
Dark, Material You dynamic colour auto-disabled < API 31, week start Auto/Mon/
Sun), language (per-app locales via AppCompat, persisted to API 29), and an
about section (version, licence, source link). Theme is driven by one
activity-scoped settings source so changes apply app-wide at once. Week start
now drives the month grid and week view; Auto follows the locale.
Also:
- default view switched from month to week
- Settings screen handles system back (was closing the app)
- fix pre-existing NonObservableLocale/LocalContextConfigurationRead lint
errors in EventDetailScreen so CI lint is green again
- versionName/versionCode bumped to 0.5.0 / 5
Tests: repository hidden-filter (incl. live re-emit), SettingsPrefs round-trip
+ week-start resolution, filter grouping. lint + unit tests + assembleDebug green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping an event in the week/day timeline opens a full-screen detail
destination (MD3 list→detail, not a bottom sheet) overlaying the calendar
with a slide transition. One card per field (when, calendar, location,
description, attendees, recurrence) with leading icons; location taps open
a maps intent. Loading/Failure/Success throughout.
Recurrence is humanized from the RRULE — e.g. "Every week on Tue and Thu
until 31 Dec 2026" — covering FREQ/INTERVAL/BYDAY/UNTIL/COUNT with
abbreviated, italicised day names and localized list formatting, falling
back to a generic label for rules it can't render.
Also:
- fix: recurring events failed to open (series row stores DURATION, not
DTEND, so the mapper dropped them as EventNotFound). The detail keeps
them and shows the tapped occurrence's own times from Instances.
- feat: month day cell → opens the day view anchored to that date.
- build: add material-icons-extended (R8 strips unused icons in release).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Day view as a one-column slice of the week view: shared TimedBlock/
AllDaySpan layout, per-day swipe navigation, hoisted noon-centred scroll,
animated all-day strip, and a compact top bar showing the full date.
- DayUiState / DayViewModel / DayScreen under ui/day
- reuse layoutDay/layoutAllDay/coversDay from the week package
- add Day to IMPLEMENTED_VIEWS; CalendarHost routes it explicitly
- day_today_action strings (en/de)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Rounded, permanently-soft day-column scroll viewport via two viewports
sharing one scroll state (gutter + columns stay aligned); plain
rectangular column cards inside
- Vertical scroll position now persists across week swipes; noon-centring
only runs on first entry into the week view (from month/day)
- All-day strip height is hoisted + animated, shared by both swipe pages,
so it slides along and resizes smoothly instead of jumping
- Multi-line event time label so the end time isn't clipped in narrow
columns; hour labels centred in the gutter
- Calendar-week (ISO) badge in the header gutter, aligned with the date
numbers; dropped the redundant "All-day" gutter label
- Small breathing room between the top section and the timeline
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the throwaway debug screen with the first real calendar UI and a
functional Month <-> Week switcher, on Material 3 Expressive.
Month view (S1):
- Material 3 Expressive card-per-day grid; only the current month's weeks
render (neighbouring days left blank)
- per-day event dots with "+N" overflow, today via primaryContainer
- spring-based press feedback from the active motion scheme
- swipe + drawer navigation, Loading/Failure/Success states
Week view (S2):
- vertical time schedule with overlap-resolved lanes (per-day clipping,
midnight spanning, instant events)
- all-day / multi-day events as connected horizontal spans
- single scroll container (gutter + day columns stay aligned), columns
bundled in a rounded container, noon-centred on load
- top section colour-shifts with the app bar on scroll; swipe navigation,
three states
Shared / infra:
- CalendarHost holds the active view; RootScreen renders it post-permission
- ui/common building blocks: CalendarDrawer, CalendarFailure,
ViewSwitcherPill, pastelize, observable locale, M3 Expressive slide
transition (motionScheme fastSpatialSpec)
- unit tests for the week layout (lanes, clipping, all-day spans)
- build: compileSdk 37, material3 pinned to 1.5.0-alpha21 for Expressive
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-render both locale catalog icons (512x512) from the same logo as the
Android adaptive launcher icon, baking in the foreground group transform
(scale 0.5, pivot 114,108, translate 2,8) over the slate background so the
F-Droid render is pixel-faithful to the on-device icon.
Add design/icon/calendula_launcher.svg as the composed full-bleed source
of truth for store/F-Droid renders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cal.id and Event.instanceId share a numeric range, and the LazyColumn
keys both sections — colliding values (e.g. cal-id=4 + event-instance-id=4)
crashed with "Key '4' was already used". Additionally, Instances._ID is
inherited from the parent Event, so recurring events produce multiple
rows with the same instanceId; the start instant disambiguates them.
Replaces the simple numeral-only foreground from v0.1.0. The new mark
keeps the kalendae reference explicit (a bold "1" inside the
calendar body) and adds a small calendula bloom as a badge in the
bottom-right corner so the app's "calendula" brand reads at first
glance.
- design/icon/calendula_mark.svg: source SVG (232x232 viewport,
monochrome, lawnicons-style strokes 12/8)
- app/src/main/res/drawable/ic_launcher_foreground.xml: regenerated
as a VectorDrawable preserving the source path data. Off-white
(#FAF6F0) strokes on the existing slate background. Reused as the
<monochrome> slot so Android 13+ themed-icon launchers can recolor
it from wallpaper.
- fdroid-metadata/.../{en-US,de-DE}/icon.png: 512x512 PNG composed
from the same source SVG with the slate background baked in, so
F-Droid clients show a fully rendered tile in the app catalog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 18:24:04 +02:00
382 changed files with 52044 additions and 6571 deletions
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
title: "Crash: "
labels:
- bug
- crash
- priority/high
---
<!--
Thanks for reporting a crash in Calendula!
If the app prefilled this for you, the crash report is already below — just add
what you were doing and submit. Otherwise, paste the report from your clipboard
into the code block. The report contains only app/Android/device versions and the
stack trace — no personal data or calendar content.
- **Tech stack pin:** Hilt 2.59.2 + KSP 2.3.9; Kotlin 2.3.21 (KSP for Kotlin 2.4.0 not released yet). Material 3 pinned to `1.5.0-alpha21` (Expressive APIs only exist in alpha). Re-evaluate when KSP/Material3 stable land.
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
</p>
Requires Android SDK 36 and JDK 17. The Gradle wrapper is checked in, so no host Gradle install is needed:
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.