Compare commits

...

179 Commits

Author SHA1 Message Date
e46c85d660 chore(deps): update agp to v9.3.1 2026-08-03 05:02:14 +00:00
Jean-Luc Makiolas Weblate Bot
44233cff35 Translations update from Weblate (#130)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 30s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Renovate / renovate (push) Successful in 1m37s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/130
2026-08-02 17:45:31 +02:00
Jean-Luc Makiolas Weblate Bot
5764bd889b Translations update from Weblate (#129)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 30s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Translations update from [Weblate](https://weblate.dev.jeanlucmakiola.de) for [Calendula/Strings](https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/).

Current translation status:

![Weblate translation status](https://weblate.dev.jeanlucmakiola.de/widget/calendula/strings/horizontal-auto.svg)

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Admin <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: augustin64 <me.git+calendula@augustin64.fr>
Co-authored-by: Thomas Tref <Trefthomas33@gmail.com>
Co-authored-by: Xavier <bxavier52@laposte.net>
Co-authored-by: Weblate <noreply@weblate.org>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/129
2026-08-01 15:42:13 +02:00
Jean-Luc Makiola
a8a83a39d0 2.18.0 (#126)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 13m15s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m34s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/126
2026-07-31 22:22:10 +02:00
Jean-Luc Makiolas Weblate Bot
9557ca73ed Translations update from Weblate (#125)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Translations update from [Weblate](https://weblate.dev.jeanlucmakiola.de) for [Calendula/Strings](https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/).

Current translation status:

![Weblate translation status](https://weblate.dev.jeanlucmakiola.de/widget/calendula/strings/horizontal-auto.svg)

Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Admin <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/125
2026-07-31 22:06:12 +02:00
Jean-Luc Makiola
9767fbbbaf Fix the sidebar's back gesture and alignment (#114) (#121)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
- 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
2026-07-31 20:50:13 +02:00
Jean-Luc Makiola
9054742503 Adjustable vertical scale for the week and day timeline (#56) (#122)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/122
2026-07-31 20:49:56 +02:00
Jean-Luc Makiola
3d62036d79 Drive the system bar icons from the in-app light/dark choice (#70) (#120)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/120
2026-07-31 20:31:50 +02:00
Jean-Luc Makiolas Weblate Bot
34a8e91ea5 Translations update from Weblate (#119)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Translations update from [Weblate](https://weblate.dev.jeanlucmakiola.de) for [Calendula/Strings](https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/).

Current translation status:

![Weblate translation status](https://weblate.dev.jeanlucmakiola.de/widget/calendula/strings/horizontal-auto.svg)

Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/119
2026-07-31 20:28:07 +02:00
Jean-Luc Makiola
0097a9c534 Fix the Play upload's AAB path (#110)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Failing after 5s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/110
2026-07-31 12:37:13 +02:00
Jean-Luc Makiola
4e51e33356 2.17.1 — re-cut 2.17.0 after the release pipeline dropped it (#109)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 9s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 14m0s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m5s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/109
2026-07-30 22:42:58 +02:00
Jean-Luc Makiola
e8657117d6 2.17.0: reminders Calendula delivers itself, one visibility model, and a Settings you can navigate (#108)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Failing after 6m29s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/108
2026-07-30 22:14:57 +02:00
Jean-Luc Makiola
d5c53df6b5 Publish the release bundle to Google Play (#84) (#100)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/100
2026-07-30 13:59:43 +02:00
Jean-Luc Makiola
2f4e05899a Make the Obtainium badge add the app in one tap (#99)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/99
2026-07-30 11:39:06 +02:00
06653bf5b1 fix(deps): update composebom to v2026.06.01 (#95)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/95
2026-07-30 11:27:24 +02:00
e952393116 fix(deps): update test dependencies (#92)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/92
2026-07-30 11:27:09 +02:00
384ba39cf9 fix(deps): update material3 (alpha) to v1.5.0-alpha24 (#91)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
fix(deps): update material3 (alpha) to v1.5.0-alpha24 #91
2026-07-30 11:26:18 +02:00
Jean-Luc Makiola
3fca28810b Don't run the Android build for forge-housekeeping changes (#98)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
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
2026-07-30 11:22:23 +02:00
Jean-Luc Makiola
c988e1f028 Fix the broken README screenshot and spell out the install channels (#97)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 9s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been cancelled
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
2026-07-30 11:22:11 +02:00
Jean-Luc Makiola
a3d136f55d Write a contributing guide, and fix what the forge move left stale (#96)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 27s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
(docs) Update all the docs to be up to date with current status quo
2026-07-30 10:40:07 +02:00
Jean-Luc Makiola
db7094c54e Move the canonical forge to Codeberg (#86)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 14s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
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
2026-07-29 20:33:56 +02:00
c70412c782 Link the privacy policy from the about card (!103)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
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
2026-07-28 17:10:52 +00:00
ac0c43f930 Translations update from Weblate (!99)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Renovate / renovate (push) Successful in 1m13s
Translations update from [Weblate](https://weblate.dev.jeanlucmakiola.de) for [Calendula/Strings](https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/).

Current translation status:

![Weblate translation status](https://weblate.dev.jeanlucmakiola.de/widget/calendula/strings/horizontal-auto.svg)

Co-authored-by: Bazyli Cyran <bazyli@cyran.dev>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Reviewed-on: #99
Co-authored-by: Weblate <weblate-bot@jeanlucmakiola.de>
Co-committed-by: Weblate <weblate-bot@jeanlucmakiola.de>
2026-07-26 08:26:54 +00:00
314236ac0c fix(renovate): use a flexible internal-checks filter so ages resolve (!100)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
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
2026-07-25 20:31:33 +00:00
c6e83fc071 chore(renovate): show real release age and fetch changelogs (!98)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
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
2026-07-25 20:09:22 +00:00
4c1bfc052e Merge pull request 'ci(release): retry the Codeberg release create past the tag-settle 500' (!96) from fix/codeberg-release-retry into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: #96
2026-07-25 07:09:53 +00:00
bba536394f ci(release): retry the Codeberg release create past the tag-settle 500
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 4m23s
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>
2026-07-24 22:22:52 +02:00
bb34973047 Merge pull request 'Release 2.16.0' (!95) from release/v2.16.0 into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m59s
Reviewed-on: #95
2026-07-24 20:00:13 +00:00
108a1890be docs(changelog): cut 2.16.0
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 10m49s
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>
2026-07-24 21:47:44 +02:00
521c0ffd78 i18n: offer Arabic in the language picker
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>
2026-07-24 18:20:07 +02:00
33a37d4105 Merge remote-tracking branch 'origin/main' into release/v2.16.0 2026-07-24 18:17:23 +02:00
6ccc0cbed2 Merge pull request 'Translations update from Weblate' (!91) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
2026-07-24 16:15:27 +00:00
Weblate
6e6ffce271 Update translation files
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 4m28s
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-07-24 16:09:17 +00:00
Sup99999
23386bb998 Translated using Weblate (Arabic)
Currently translated at 52.4% (229 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-07-24 16:09:17 +00:00
Xavier
28b1568486 Translated using Weblate (French)
Currently translated at 91.0% (398 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-07-24 16:09:17 +00:00
Sup99999
5127971a37 Translated using Weblate (Arabic)
Currently translated at 32.7% (143 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-07-24 16:09:17 +00:00
Weblate
0562af8d66 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-07-24 16:09:17 +00:00
Sup99999
e3d3728bbd Added translation using Weblate (Arabic) 2026-07-24 16:09:17 +00:00
Weblate
8cc9d075fd Translated using Weblate (Italian)
Currently translated at 100.0% (437 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-24 16:09:17 +00:00
Sam Gamgee
ede6d967c1 Translated using Weblate (Italian)
Currently translated at 100.0% (437 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-24 16:09:17 +00:00
c30f6ab318 Merge pull request 'build(lint): don't fail the build on partial community plurals' (!94) from fix/lint-partial-plurals into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
2026-07-24 16:09:12 +00:00
dd2b96b5fd build(lint): don't fail the build on partial community plurals
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m49s
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>
2026-07-24 17:57:51 +02:00
c22658db4c Merge pull request 'fix(intents): show Calendula under "Add to calendar" from other apps (#74)' (!93) from fix/insert-event-intent-filters into release/v2.16.0
Reviewed-on: #93
2026-07-24 15:46:13 +00:00
7b3893ddc6 feat(intents): open .ics/.vcs handed over as application/octet-stream (#74)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 10m43s
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>
2026-07-24 17:40:27 +02:00
9e28fa4981 fix(intents): accept item-typed INSERT / INSERT_OR_EDIT for "Add to calendar" (#74)
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>
2026-07-24 17:37:59 +02:00
7549c8fe04 Merge pull request 'fix(pickers): keep full-screen picker bar icons theme-matched (#70)' (!92) from fix/picker-status-bar-icons into release/v2.16.0
Reviewed-on: #92
2026-07-24 15:26:24 +00:00
75f699b714 chore(floret-kit): re-point submodule to merged main (#70)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 2m32s
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>
2026-07-24 17:24:57 +02:00
3e821c9092 fix(pickers): keep full-screen picker bar icons theme-matched (#70)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m26s
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>
2026-07-24 17:12:29 +02:00
7602f158a3 Merge pull request 'feat(month): month view style picker + Split pull-to-expand (#38, #53)' (!90) from feat/month-view-style into release/v2.16.0
Reviewed-on: #90
2026-07-20 21:25:49 +00:00
052335e842 fix(month): carry the scroll position across a style change (#38)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m54s
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>
2026-07-20 23:20:34 +02:00
28fb7404d8 fix(month): point the split slide the way the month moves (#53)
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>
2026-07-20 23:20:21 +02:00
6ab235e1d8 refactor(month): make the style picker read by looking, not comparing (#38)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m35s
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>
2026-07-20 22:55:40 +02:00
fb90c94e37 fix(month): let the selection outline travel with its cell (#53)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 5m44s
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>
2026-07-20 22:19:19 +02:00
7a05fc3ac1 fix(month): give every covered day's dot a place inside the bar (#53)
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>
2026-07-20 22:06:22 +02:00
bf38dac5d7 fix(month): only tag the dot that has a bar to become (#53)
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>
2026-07-20 21:51:45 +02:00
1e9581528e fix(month): stop the row clip swallowing marks in mid-flight (#53)
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>
2026-07-20 21:40:46 +02:00
279872aa3b fix(month): morph in place instead of over the top of the grid (#53)
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>
2026-07-20 21:09:25 +02:00
ff6c4b6a4d fix(month): let the event marks actually grow, and lift the "+N" out (#53)
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>
2026-07-20 20:55:30 +02:00
7fb01ab8d1 fix(month): fade the selection outline with the page it belongs to (#53)
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>
2026-07-20 20:43:21 +02:00
bdb16d069e fix(month): drag from the handle, and stop the outline hiding behind its cell (#53)
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>
2026-07-20 20:34:56 +02:00
beb8536e8a feat(calendar): commit page swipes on the threshold, not on release (#53)
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>
2026-07-20 20:25:18 +02:00
63ba69729e fix(month): keep the selection outline off the morphing cell (#53)
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>
2026-07-20 19:57:29 +02:00
01a6c8bab8 revert(month): put the morph back on the calendar's own motion specs (#53)
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>
2026-07-20 19:51:57 +02:00
f70b33b864 fix(month): drop the expanded grid's outline and make the pull fire sooner (#53)
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>
2026-07-20 19:48:03 +02:00
0df420e551 feat(month): collapse the expanded month on back (#53)
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>
2026-07-20 19:37:44 +02:00
488de490f9 feat(month): morph the split style's dots into the expanded grid (#53)
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>
2026-07-20 19:36:13 +02:00
f15fffa799 feat(month): let the split style pull open to the full month (#53)
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>
2026-07-20 19:30:37 +02:00
1499b5803d fix(month): seat the split style's dots by lane (#53)
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>
2026-07-20 19:26:57 +02:00
39cc70be95 fix(month): keep the split selection inside the month that owns it (#53)
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>
2026-07-20 19:10:08 +02:00
f9619e74d9 style(month): settle the split style's selection and day pane (#53)
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>
2026-07-20 19:04:08 +02:00
e691336b5e style(calendar): make the page transition a proper shared-axis (#38, #53)
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>
2026-07-20 18:57:45 +02:00
4cee160c12 fix(month): steady the split style's paging (#53)
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>
2026-07-20 18:52:32 +02:00
a7ae8ff912 style(month): let the outline alone mark the split style's selected day (#53)
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>
2026-07-20 18:46:18 +02:00
d23a1d5b6d style(month): drop the split style's divider (#53)
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>
2026-07-20 18:45:32 +02:00
b417900ddd perf(month): bucket events by day before laying out the scrolling grids (#38)
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>
2026-07-20 18:41:03 +02:00
95f4d28bbb perf(month): open the scrolling styles on a small window and grow it (#38)
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>
2026-07-20 18:23:06 +02:00
24b005126e feat(month): add the Dense style, rule under the Continuous header (#38)
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>
2026-07-20 18:10:54 +02:00
ee9d65068d fix(month): don't drag the continuous window back to 1900 (#38)
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>
2026-07-20 17:59:53 +02:00
6e3d10858f refactor(month): make the continuous style a stack of month blocks (#38)
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>
2026-07-20 17:54:26 +02:00
94a82c2f6c refactor(settings): make the month style picker a live preview (#38, #53)
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>
2026-07-20 17:34:16 +02:00
81dcbbdce7 docs(changelog): month view style (#38, #53)
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>
2026-07-20 17:02:18 +02:00
becb9a6710 feat(month): split style with a day pane (#53)
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>
2026-07-20 17:00:22 +02:00
542744e342 feat(month): continuous vertical scrolling (#38)
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>
2026-07-20 16:54:41 +02:00
7510e1f9af feat(settings): add the month view style picker (#38, #53)
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>
2026-07-20 16:45:42 +02:00
498250650c refactor(month): extract per-week layout and share the agenda's rows
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>
2026-07-20 16:36:57 +02:00
ada6976e85 Merge pull request 'fix(edit): recurrence picker review fixes + picker unification (#42)' (!89) from feat/recurrence-exdate into release/v2.16.0
Reviewed-on: #89
2026-07-20 13:29:15 +00:00
6a454e0b33 chore: point floret-kit at merged main
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m40s
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>
2026-07-20 15:28:05 +02:00
cd6ae31d0f Merge remote-tracking branch 'origin/release/v2.16.0' into feat/recurrence-exdate
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 11m20s
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
2026-07-20 15:24:05 +02:00
463811056d refactor(ui): one selection check and one motion spec across the pickers
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 5m57s
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>
2026-07-20 15:20:37 +02:00
98d76deee5 fix(edit): correct the recurrence picker's read-out and end handling
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>
2026-07-20 15:20:15 +02:00
20e18768eb Merge pull request 'fix(widget): scale the agenda widget with its size (#51)' (!88) from fix/agenda-widget-size-scaling into release/v2.16.0
Reviewed-on: #88
2026-07-20 13:11:17 +00:00
a34f29bcf8 fix(widget): address review of the agenda size scaling (#51)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m52s
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>
2026-07-20 14:47:37 +02:00
997296e21e Merge pull request 'fix: address code-review findings on the 2.16.0 branch' (!87) from fix/code-review-2.16.0 into release/v2.16.0 2026-07-20 11:52:20 +00:00
94355bf340 fix: address code-review findings on the 2.16.0 branch
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m43s
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>
2026-07-20 13:10:39 +02:00
722fc87259 fix(edit): honour week-start pref in recurrence picker + smooth weekday collapse
- 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>
2026-07-20 13:04:57 +02:00
30fcbfa59f fix(widget): scale the agenda widget with its size (#51)
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>
2026-07-20 12:49:19 +02:00
c956ac79ed Merge pull request 'feat(settings): optional "Calendar" launcher name (#44)' (!86) from feat/app-name-toggle into release/v2.16.0
Reviewed-on: #86
2026-07-20 10:45:55 +00:00
b90f07a816 ci(release): push tag to Codeberg before creating the release
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Renovate / renovate (push) Successful in 1m13s
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>
2026-07-19 22:48:30 +02:00
7d357bad87 feat(settings): App name picker shows both names as preview cards
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m10s
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>
2026-07-19 17:09:10 +02:00
3ffe76412e feat(settings): launcher-mark hero on the App name picker
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>
2026-07-19 17:05:14 +02:00
91c4a84818 refactor(settings): app-name as a full-screen chooser, not a switch
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>
2026-07-19 16:57:28 +02:00
ccd433fee1 refactor(edit): redesign the custom recurrence picker (#42)
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>
2026-07-19 16:56:55 +02:00
03f6b7c8c1 feat(settings): optional "Calendar" launcher name (#44)
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>
2026-07-19 16:39:08 +02:00
426ddf27ee Merge pull request 'fix(agenda): stop yesterday's all-day event surfacing under today (#65)' (!85) from fix/agenda-allday-today into release/v2.16.0
Reviewed-on: #85
2026-07-19 14:14:49 +00:00
4148196a36 fix(agenda): stop yesterday's all-day event surfacing under today (#65)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m53s
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>
2026-07-19 16:05:36 +02:00
9a1d753abc Merge pull request 'feat(views): optional jump-to-today button in the toolbar (#60)' (!84) from feat/today-button-in-toolbar into release/v2.16.0
Reviewed-on: #84
2026-07-19 10:26:09 +00:00
1b29abed17 docs(changelog): note the toolbar today-button option (#60)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m3s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 12:24:08 +02:00
85d72ad051 refactor(views): extract shared TodayAction top-bar button
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>
2026-07-19 12:18:47 +02:00
fd3bcdf2f7 Merge pull request 'fix(agenda): show multi-day events on every day they span' (!83) from fix/agenda-multiday into release/v2.16.0
Reviewed-on: #83
2026-07-19 10:16:56 +00:00
46afa830b3 feat(views): optional jump-to-today button in the toolbar (#60)
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>
2026-07-19 12:12:54 +02:00
7de9b2f81b fix(agenda): share the day-aware time label with the widget
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 5m45s
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>
2026-07-19 12:06:40 +02:00
3ec46c8631 fix(agenda): resolve all-day span days in UTC, not the device zone
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>
2026-07-19 11:53:21 +02:00
529dc9c374 Merge remote-tracking branch 'origin/release/v2.16.0' into fix/agenda-multiday 2026-07-19 11:50:29 +02:00
60ef920b82 Merge pull request 'feat: per-event time zones (#31)' (!82) from feat/timezone-support into release/v2.16.0
Reviewed-on: #82
2026-07-19 09:49:13 +00:00
7b7b859fee docs(changelog): note per-event time zones (#31)
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 9m50s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:47:13 +02:00
53d42341c0 Merge pull request 'Translations update from Weblate' (!80) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: #80
2026-07-19 09:42:15 +00:00
b5c2930609 feat(agenda): spell out multi-day time line instead of arrows
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>
2026-07-19 11:42:15 +02:00
3feafe38f2 feat(agenda): day-aware time line for multi-day events
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>
2026-07-19 11:39:01 +02:00
55ffaad94f Translated using Weblate (German)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m37s
Currently translated at 97.4% (426 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-19 09:37:33 +00:00
Weblate
5bdde146c1 Translated using Weblate (German)
Currently translated at 97.4% (426 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-19 09:37:33 +00:00
Weblate
97f87fb927 Translated using Weblate (French)
Currently translated at 52.8% (231 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-07-19 09:37:33 +00:00
augustin64
469166c0bb Translated using Weblate (French)
Currently translated at 52.8% (231 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-07-19 09:37:33 +00:00
Pablo
b10965babe Translated using Weblate (Russian)
Currently translated at 14.1% (62 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-07-19 09:37:33 +00:00
Weblate
e5c122a2ee Translated using Weblate (Polish)
Currently translated at 100.0% (437 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-07-19 09:37:33 +00:00
Bazyli Cyran
3aefc5c8f8 Translated using Weblate (Polish)
Currently translated at 100.0% (437 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-07-19 09:37:33 +00:00
Bazyli Cyran
ecc643ac81 Translated using Weblate (Polish)
Currently translated at 97.2% (417 of 429 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-07-19 09:37:33 +00:00
8e4d0defa2 Merge pull request 'ci(release): mirror releases to Codeberg (APK + checksum)' (!81) from feat/codeberg-release-publish into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: #81
2026-07-19 09:37:29 +00:00
19baed9291 fix(agenda): scope LazyColumn key by day for spanning events
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>
2026-07-19 11:30:00 +02:00
4ea68adf8b feat(timezone): let the picker search by abbreviation
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>
2026-07-19 11:25:22 +02:00
f459f7d39c ci(release): use CODEBERG_RELEASE_TOKEN secret name
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 4m29s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:23:49 +02:00
c3ba8ccf64 test: fix CalendarRepositorySmokeTest against the current constructor
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>
2026-07-19 11:19:53 +02:00
7ed1d89b66 fix(agenda): list multi-day events on every day they span
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>
2026-07-19 11:16:08 +02:00
734bcae02d fix(timezone): resolve the abbreviation in the zone's own region
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>
2026-07-19 11:15:48 +02:00
629896b74d fix(timezone): resolve the abbreviation via java.util, not java.time
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>
2026-07-19 11:01:25 +02:00
6a49539ae5 ci(release): mirror each release to Codeberg with APK + checksum
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>
2026-07-19 10:56:13 +02:00
01ac61185b fix(timezone): show the IANA id + abbreviation, not the long localized name
"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>
2026-07-19 10:48:21 +02:00
ca01f6e729 fix(detail): keep the zone card's hierarchy matching the When card
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>
2026-07-17 16:01:08 +02:00
0588609c75 fix(edit): use the family's text input, and show both times when zones differ
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>
2026-07-17 15:54:33 +02:00
ab3df639b9 chore: pin floret-kit to v0.2.1
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>
2026-07-17 15:37:56 +02:00
fefe1f3652 feat(edit): add a searchable time-zone picker (#31)
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>
2026-07-17 15:32:04 +02:00
7634df3cff feat(domain): let an event pin its own time zone (#31)
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>
2026-07-17 15:31:54 +02:00
60938af9f0 chore: pin floret-kit to v0.2.0
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>
2026-07-17 14:56:55 +02:00
accf0ac142 release: cut 2.16.0
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>
2026-07-17 14:53:13 +02:00
f90badfcd5 Merge branch 'feat/unify-date-formatting' into release/v2.16.0 2026-07-17 14:51:41 +02:00
249606a358 fix(i18n): unify the calendar titles on one locale-aware formatter (#60)
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>
2026-07-17 09:47:58 +02:00
5be5ff77d0 Merge pull request 'Release 2.15.0' (!79) from release/v2.15.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea release / release (push) Successful in 13m40s
2026-07-15 18:54:02 +00:00
6519cbca79 release: cut 2.15.0
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 11m17s
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>
2026-07-15 20:34:52 +02:00
50510a23da feat(colors): optional raw calendar colours + readable title contrast
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>
2026-07-15 20:26:34 +02:00
2279738371 docs(changelog): note the new French and Polish translations
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:42:19 +02:00
3d59962112 i18n: offer French and Polish in the language picker
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>
2026-07-15 19:40:10 +02:00
a96d8b6de9 Merge remote-tracking branch 'origin/main' into release/v2.15.0 2026-07-15 19:39:08 +02:00
94b9eeaa78 Merge pull request 'Translations update from Weblate' (!78) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #78
2026-07-15 17:38:51 +00:00
Bazyli Cyran
85fb091e22 Translated using Weblate (Polish)
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m31s
Currently translated at 58.7% (252 of 429 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-07-15 17:34:15 +00:00
2139f8729c Merge remote-tracking branch 'origin/main' into release/v2.15.0 2026-07-15 19:28:22 +02:00
98a48aa795 docs(changelog): note the agenda date localization and gutter alignment fixes
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>
2026-07-15 19:27:20 +02:00
d255d232b9 Merge pull request 'Translations update from Weblate' (!76) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #76
2026-07-15 17:24:26 +00:00
9999fcdd2a Merge pull request 'feat(detail): duplicate an event as a new one (#52)' (!77) from feat/duplicate-events into release/v2.15.0
Reviewed-on: #77
2026-07-15 17:23:44 +00:00
953ffdff97 fix(day): align the hour-gutter labels with the top bar hamburger
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>
2026-07-15 19:19:42 +02:00
5377a2b466 fix(calendar): align the week-number gutter with the top bar hamburger
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>
2026-07-15 19:15:51 +02:00
e73148dc6c fix(widget): keep the empty-today line plain, not a card
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>
2026-07-15 19:15:51 +02:00
a19772e3a7 docs(changelog): note the duplicate-event action (#52)
All checks were successful
Translations / check (pull_request) Successful in 26s
CI / ci (pull_request) Successful in 9m15s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:11:34 +02:00
b3386eff43 Merge branch 'release/v2.15.0' into feat/duplicate-events 2026-07-15 19:10:29 +02:00
79e74d9995 feat(detail): duplicate an event as a new one (#52)
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>
2026-07-15 19:10:23 +02:00
396a5610aa fix(agenda): localize dates and refine the range bar
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>
2026-07-15 19:02:37 +02:00
b9329f6fb6 feat(agenda): anchor today with an empty-day card (#35)
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>
2026-07-15 18:39:27 +02:00
1b731a4ab0 docs(changelog): note the ACTION_EDIT intent and .ics MIME types
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:25:56 +02:00
ab631365b2 feat(intent): handle ACTION_EDIT and broaden .ics MIME types
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>
2026-07-15 17:23:40 +02:00
9aa370d583 Merge remote-tracking branch 'origin/main' into release/v2.15.0
# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt
2026-07-15 17:23:31 +02:00
iker Contreras
8cd75716ae Translated using Weblate (Spanish)
All checks were successful
Translations / check (pull_request) Successful in 27s
CI / ci (pull_request) Successful in 10m52s
Currently translated at 100.0% (429 of 429 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/es/
2026-07-14 02:41:02 +00:00
Thomas Tref
4feccf2008 Translated using Weblate (French)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m14s
Currently translated at 30.5% (131 of 429 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-07-13 23:05:35 +00:00
23e9a5a35b Merge pull request 'fix(reminders): show the day for reminders on another day (#46)' (!75) from fix/reminder-day-context into release/v2.15.0
Reviewed-on: #75
2026-07-13 15:35:03 +00:00
38a35be0f0 fix(reminders): show the day for reminders on another day (#46)
All checks were successful
CI / ci (pull_request) Successful in 5m31s
Translations / check (pull_request) Successful in 5s
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>
2026-07-13 17:25:51 +02:00
f9f0572ec5 Merge pull request 'docs(changelog): add the custom snooze duration entry (#40)' (!74) from docs/changelog-snooze into release/v2.15.0
Reviewed-on: #74
2026-07-13 14:39:18 +00:00
0221972e6d docs(changelog): add the custom snooze duration entry (#40)
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 12s
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>
2026-07-13 16:38:35 +02:00
86ea72d56f Merge pull request 'feat(edit): move an event to another calendar (#39)' (#73) from feat/change-event-calendar into release/v2.15.0 2026-07-13 14:30:07 +00:00
114db7939c ci(translations): run on every PR so the required check always reports
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 2m27s
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>
2026-07-13 16:27:10 +02:00
4503847c0d feat(edit): move an event to another calendar (#39)
All checks were successful
CI / ci (pull_request) Successful in 8m40s
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>
2026-07-13 16:11:32 +02:00
60aa889c57 Merge pull request 'feat(settings): custom snooze duration, extract editor card to floret-kit' (!67) from feat/custom-snooze-duration into release/v2.15.0
Reviewed-on: #67
2026-07-08 16:10:32 +00:00
3c9767387b feat(settings): custom snooze duration, extract editor card to floret-kit
All checks were successful
CI / ci (pull_request) Successful in 10m14s
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>
2026-07-08 18:02:13 +02:00
199 changed files with 21422 additions and 5029 deletions

View File

@@ -0,0 +1,30 @@
---
name: Bug report
about: Something doesn't work the way it should
title: ""
labels:
- bug
---
### What happened
### What you expected
### Steps to reproduce
1.
2.
3.
### Environment
- Calendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:
- Installed from: <!-- official F-Droid / the self-hosted repo / built from source -->
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
subscribed/WebCal, birthdays — provider behaviour differs
a lot per account type, so this often points straight at
the cause -->
- Time zone: <!-- only if the problem involves dates or all-day events -->

View File

@@ -0,0 +1,18 @@
# Kept enabled so anything that doesn't fit the four templates still has a way
# in (the `ToDo` label exists for exactly those).
blank_issues_enabled: true
contact_links:
- name: Translate Calendula
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
about: >-
Translations are managed on Weblate, not here — it owns every values-*
file, so a hand-edited translation gets overwritten on the next sync.
No coding needed: pick or request a language and translate in the browser.
- name: Contributing guide
url: https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
about: >-
Before opening a pull request: the issue-first workflow, which release
branch to target, how to build (there's a submodule), and the
architectural rules a change is reviewed against.

View File

@@ -5,7 +5,7 @@ title: "Crash: "
labels: labels:
- bug - bug
- crash - crash
- priority:high - priority/high
--- ---
<!-- <!--

View File

@@ -0,0 +1,41 @@
<!--
Thanks for contributing to Calendula!
Please skim CONTRIBUTING.md if you haven't:
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
Two things it's easy to get wrong:
• Features need a discussed issue first — an undiscussed feature PR may be
closed unmerged even when the code is good.
• Target the release branch for your issue's milestone (milestone 2.18.0 →
release/v2.18.0), not main. If you targeted main, just say so below and it
will be retargeted.
-->
### What this changes
### Why
<!-- Closes #123 — link the issue this implements or fixes. -->
### How it was tested
<!--
Which of these ran green, and anything you exercised by hand. On-device notes
are especially useful for UI changes.
./gradlew lint test assembleDebug
python3 scripts/check_translations.py
-->
### Checklist
- [ ] There's an issue for this, and (for a feature) it got a go-ahead
- [ ] Targeting the release branch for that issue's milestone — or `main`, noted above
- [ ] `./gradlew lint test assembleDebug` passes locally
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
- [ ] No planning or design documents committed

View File

@@ -37,14 +37,29 @@ jobs:
- name: Reproducible-release invariant - name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh run: bash scripts/check_reproducible_release.sh
# Decide whether anything that affects the app build changed. Docs, # Decide whether anything that affects the app build changed. Docs, store
# F-Droid metadata and the licence don't, so those PRs skip the SDK + # metadata, licence texts and forge housekeeping don't, so those PRs skip
# Gradle work below but still report a green `ci`. # the SDK + Gradle work below but still report a green `ci`.
- name: Classify change scope - name: Classify change scope
id: scope id: scope
env:
# Deliberately a skip-list, not a build-list: a path nobody thought
# about defaults to building. Only paths the Gradle build provably
# never reads belong here — note that the workflows themselves, the
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^licenses/|^\.planning/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^renovate\.json5$|^LICENSE$)'
run: | run: |
set -e set -e
BASE="${{ github.base_ref }}" BASE="${{ github.base_ref }}"
# Normally the bare branch name; tolerate a full ref, which would
# otherwise make the merge-base lookup fail and quietly degrade this
# guard into "always build".
BASE="${BASE#refs/heads/}"
if [ -z "$BASE" ]; then
echo "No base branch on this event — running the full build to be safe."
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Full (not --depth=1) base fetch so the merge-base is present even when # Full (not --depth=1) base fetch so the merge-base is present even when
# the PR branch forked several commits back; a shallow tip has no merge # the PR branch forked several commits back; a shallow tip has no merge
# base with a divergent branch and `git diff base...HEAD` aborts. # base with a divergent branch and `git diff base...HEAD` aborts.
@@ -58,11 +73,15 @@ jobs:
fi fi
CHANGED=$(git diff --name-only "$MB" HEAD) CHANGED=$(git diff --name-only "$MB" HEAD)
echo "Changed files:"; echo "$CHANGED" echo "Changed files:"; echo "$CHANGED"
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then RELEVANT=$(echo "$CHANGED" | grep -vE "$SKIP_RE" || true)
if [ -n "$RELEVANT" ]; then
# Naming them makes "why did my docs PR build for four minutes?"
# answerable from the log alone.
echo "Build-relevant changes:"; echo "$RELEVANT"
echo "code=true" >> "$GITHUB_OUTPUT" echo "code=true" >> "$GITHUB_OUTPUT"
else else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build." echo "Docs/metadata-only change — skipping the Android build."
echo "code=false" >> "$GITHUB_OUTPUT"
fi fi
- name: Setup Java - name: Setup Java
@@ -72,9 +91,14 @@ jobs:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
# setup-java and cache all exist there, but android-actions/setup-android
# does not, and the job dies with "repository not found". Gitea's instance
# defaults to GitHub, which is why this never surfaced before the split.
- name: Setup Android SDK - name: Setup Android SDK
if: steps.scope.outputs.code == 'true' if: steps.scope.outputs.code == 'true'
uses: android-actions/setup-android@v3 uses: https://github.com/android-actions/setup-android@v3
with: with:
# Default ("tools platform-tools") drags in the Android Emulator # Default ("tools platform-tools") drags in the Android Emulator
# (~300 MB) which the build never uses. # (~300 MB) which the build never uses.

View File

@@ -3,13 +3,15 @@ name: Translations
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which # Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android # only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation). # build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
#
# Runs on every PR (no path filter) so the required "Translations / check"
# status is always reported — like the `ci` job. A path-filtered workflow is
# skipped on unrelated PRs and never posts its status, which leaves that
# required check pending forever and blocks the merge of any code-only PR into a
# release/* branch. The check itself is cheap and simply passes when the
# committed translations are consistent, so always running it costs nothing.
on: on:
pull_request: pull_request:
paths:
- 'app/src/main/res/values*/strings.xml'
- 'app/src/main/res/xml/locales_config.xml'
- 'scripts/check_translations.py'
- '.gitea/workflows/translations.yaml'
concurrency: concurrency:
group: translations-${{ github.ref }} group: translations-${{ github.ref }}

View File

@@ -1,23 +0,0 @@
---
name: Bug report
about: Something doesn't work the way it should
title: ""
labels:
- bug
---
### What happened
### What you expected
### Steps to reproduce
1.
2.
3.
### Environment
- Calendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -1,11 +1,19 @@
name: Release — F-Droid repo + Gitea release name: Release — F-Droid repo + Gitea/Codeberg release + Play
# A release is cut by merging a release branch into main with a bumped # A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and, # versionName (see docs/RELEASING.md). This workflow reads that versionName and,
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes # if no matching tag exists yet, runs tests, builds + signs the APK, publishes
# it to the F-Droid repo, and only then creates the vX.Y.Z tag + Gitea release # it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
# itself — the tag is an output of the pipeline, not its trigger. Ordinary # that release to Codeberg with the signed APK + a SHA-256 checksum as a
# merges (no version bump) fall through `detect` and do nothing. # direct-download channel — the tag is an output of the pipeline, not its
# trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing.
#
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
# and separate because Play is the only channel that can reject a good build for
# reasons the pipeline can't see, and that must not endanger a release which has
# already shipped to F-Droid and Codeberg. It skips cleanly until the
# PLAY_SERVICE_ACCOUNT_JSON secret exists.
# #
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery # A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
# path: it re-signs the existing F-Droid index with the repo key and re-uploads, # path: it re-signs the existing F-Droid index with the repo key and re-uploads,
@@ -25,6 +33,14 @@ jobs:
# whether this push actually cuts a new release (no tag for it yet). Keeps the # whether this push actually cuts a new release (no tag for it yet). Keeps the
# heavy job from running on every merge to main. # heavy job from running on every merge to main.
detect: detect:
# Gitea only. The workflow directory split already keeps this file invisible
# to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
# exists — but that only holds while .forgejo/ is non-empty. Move the last
# file out of it and Codeberg would fall back to .gitea/workflows and start
# running the release pipeline on the contributor-facing runner, with no
# secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj'
runs-on: docker runs-on: docker
outputs: outputs:
is_release: ${{ steps.v.outputs.is_release }} is_release: ${{ steps.v.outputs.is_release }}
@@ -39,8 +55,16 @@ jobs:
- name: Resolve version and whether it is a new release - name: Resolve version and whether it is a new release
id: v id: v
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} # Tags are read from Codeberg, which is canonical — deliberately NOT
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} # from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
# source does not have. A tag minted here on Gitea is therefore wiped
# by the next sync (Codeberg does not have it yet) and only reappears
# once the tag push at the end of this workflow propagates back.
# Asking Gitea inside that window would report "no tag" for a release
# that already shipped, and cut it a second time.
# Public repo, so this read needs no token.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
run: | run: |
set -e set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
@@ -58,15 +82,28 @@ jobs:
fi fi
# A tag for this version already existing means the release shipped on # A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release. # an earlier push; do nothing. Absent => this merge cuts the release.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \ #
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION") # Anything other than a clean 200/404 is treated as fatal rather than
if [ "$STATUS" = "200" ]; then # as "no tag". A Codeberg outage or a network blip would otherwise
echo "Tag v$VERSION already exists — nothing to release." # read as absent and re-cut a release that has already shipped —
# republishing to F-Droid and Play. Failing here is recoverable; a
# duplicate release is not.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT" echo "is_release=false" >> "$GITHUB_OUTPUT"
else ;;
echo "No tag for v$VERSION yet — cutting the release." 404)
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT" echo "is_release=true" >> "$GITHUB_OUTPUT"
fi ;;
*)
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
exit 1
;;
esac
# Releases: build + sign + publish, then mint the tag and Gitea release. # Releases: build + sign + publish, then mint the tag and Gitea release.
# Also runs on manual dispatch, where it skips the build and just re-signs and # Also runs on manual dispatch, where it skips the build and just re-signs and
@@ -227,9 +264,10 @@ jobs:
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk" cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
# Per-version "What's New": ensure this version's changelog exists in the # Per-version "What's New": ensure this version's changelog exists in the
# fastlane tree (committed at release-cut time for the official repo; this # fastlane tree. The committed hand-written summary (kept under Play's
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on # 500-char cap) is used as-is; only if it is missing does the script fall
# the commit having happened). The transform below then carries it across. # back to CHANGELOG.md, so the self-hosted repo never depends on the
# commit having happened. The transform below then carries it across.
- name: Ensure this version's changelog is in the fastlane tree - name: Ensure this version's changelog is in the fastlane tree
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh run: bash scripts/sync_changelog_to_fastlane.sh
@@ -350,3 +388,268 @@ jobs:
curl -s -X POST -H "Authorization: token $TOKEN" \ curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \ -F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n" "$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# Mirror the release to the Codeberg mirror as a direct-download channel
# for users who don't want F-Droid. Gitea already push-mirrors branches +
# tags to Codeberg, but releases aren't git objects so they don't sync —
# we create the release there over the API and attach the signed APK plus
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
# key), so this adds no trust surface. Best-effort: a Codeberg outage
# (it 504s under load) must never fail an already-published F-Droid
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
- name: Publish release to Codeberg
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
SHA: ${{ github.sha }}
run: |
set -e
if [ -z "${TOKEN:-}" ]; then
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
TAG="v$VERSION"
APK="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
ASSET_APK="calendula_v${VERSION}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "/tmp/$ASSET_APK"
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Release notes: reuse the section extracted for the Gitea release,
# fall back to the CHANGELOG entry if that step's file is gone.
if [ ! -s release-notes.md ]; then
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# The pipeline creates the tag via the Gitea API, which the push mirror
# (sync_on_commit only fires on real git pushes) doesn't propagate
# promptly — so a release POST that carries a target_commitish can
# outrun the mirror and 500 on a commit/tag Codeberg hasn't received.
# Push the tag straight to Codeberg so it's guaranteed present, then
# attach the release to that existing tag with NO target_commitish
# (which is what triggered the 500).
git tag -f "$TAG" "$SHA"
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/calendula.git" \
"refs/tags/$TAG"
python3 - "$TAG" <<'PY' > cb-payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
"prerelease": False,
}))
PY
# Create (or update) the release. Codeberg 500s on a POST/GET against a
# tag it has only just received — the release request outruns the
# indexing of the ref we pushed a moment ago — so a single attempt kept
# failing and skipping the mirror even though the very same call
# succeeds seconds later. Retry with backoff, and PATCH in place if a
# release already exists (re-run safe). A 5xx body still exits curl 0,
# so the loop, not `set -e`, controls the flow.
ID=""
for attempt in 1 2 3 4 5 6; do
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$EXIST" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$EXIST"
ID="$EXIST"; break
fi
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases")
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
[ -n "$ID" ] && break
sleep $((attempt * 10))
done
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
for A in "$ASSET_APK" "$ASSET_SUM"; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
# Play takes an App Bundle, not the APK, so it is a second artifact from
# the same source and the same signing config — not a repackage of the
# APK. The release key signs it, but Play only ever treats that key as the
# *upload* key: Play App Signing re-signs with Google's own key before
# delivery. A Play install and an F-Droid install therefore carry
# different signatures and cannot update each other. That divergence is a
# deliberate, documented choice (docs/RELEASING.md), not an accident.
#
# Built LAST and `continue-on-error`, both deliberately: everything above
# has already shipped by this point, and nothing Play-related may put that
# at risk. Sitting mid-job without continue-on-error, this block took the
# whole 2.17.0 release down with it — no F-Droid publish, no tag, no
# Codeberg mirror — over an artifact upload. A failure here now costs the
# Play upload and nothing else.
#
# Nothing here touches the F-Droid path: the AAB is never copied into the
# repo, never attached to a release, and its build cannot change the APK
# published above.
#
# AGP embeds the R8 mapping in the bundle's BUNDLE-METADATA, so Play gets
# deobfuscated stacktraces without a separate mapping upload.
- name: Build release AAB
if: env.IS_RELEASE == 'true'
continue-on-error: true
run: ./gradlew bundleRelease
# NOT actions/upload-artifact@v4: it runs @actions/artifact v2, which
# refuses to start whenever GITHUB_SERVER_URL is not github.com — it reads
# any other forge as an unsupported GHES instance and fails before it ever
# talks to the server (go-gitea/gitea#36024). Gitea 1.25 serves the v4
# artifact API fine; only the client-side check is wrong. This fork is that
# client with the check removed. Pinned to a commit, not the v4 branch: a
# third-party action in the signing pipeline must not change under us.
- name: Hand the AAB to the Play job
if: env.IS_RELEASE == 'true'
continue-on-error: true
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
with:
name: release-aab-${{ needs.detect.outputs.version }}
path: app/build/outputs/bundle/release/app-release.aab
if-no-files-found: error
retention-days: 14
# Google Play channel.
#
# A separate job, on purpose, running only AFTER the F-Droid publish and both
# forge releases have completed. Play is the one channel that can reject a
# perfectly good build for reasons outside the pipeline (listing rules, policy
# review, API outage, a track that needs manual promotion). Isolating it means
# such a rejection surfaces as one red job next to a release that already
# shipped everywhere else, instead of failing the workflow that publishes it.
#
# Not a `container:` job even though a fastlane image exists: act_runner does
# not provide node inside custom job containers, so JavaScript actions
# (checkout, download-artifact) can't run there. The Renovate job gets away
# with a container because its only step is a shell command. Ruby is installed
# the same way sshpass, jq and fdroidserver are in the job above.
play:
needs: [detect, release]
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
# touch Play, so gate on a real release only.
if: needs.detect.outputs.is_release == 'true'
runs-on: docker
env:
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
# Where the bundle lands. `internal` by default so a release reaches
# testers rather than the public, and promotion to production stays a
# deliberate human action in the Play Console — the same posture as
# holding UI releases for on-device review. Override with the PLAY_TRACK
# repo variable once the flow is trusted.
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'internal' }}
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
# Set PLAY_DRY_RUN=true to validate the edit against the API and discard
# it instead of committing — used to rehearse the first upload.
PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }}
BUNDLE_PATH: vendor/bundle
steps:
- name: Checkout
uses: actions/checkout@v4
# Skip cleanly (not fatally) when Play isn't configured yet, so the rest
# of the release pipeline keeps working during setup — same contract as
# the Codeberg mirror step.
- name: Write the Play service-account key
id: key
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
set -euo pipefail
if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
echo "PLAY_SERVICE_ACCOUNT_JSON not set — skipping the Play upload."
echo "configured=false" >> "$GITHUB_OUTPUT"
exit 0
fi
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
# Fail here, with a clear message, rather than inside fastlane: a
# mangled multi-line secret is the likeliest setup mistake.
python3 -c "import json,sys; d=json.load(open('play-service-account.json')); sys.exit(0 if d.get('type')=='service_account' else 1)" \
|| { echo "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; }
echo "configured=true" >> "$GITHUB_OUTPUT"
# Same GHES-detection problem as the upload side, same fix — see the
# handoff step in the release job.
- name: Download the AAB
if: steps.key.outputs.configured == 'true'
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4
with:
name: release-aab-${{ needs.detect.outputs.version }}
path: dist
- name: Install Ruby
if: steps.key.outputs.configured == 'true'
run: |
set -euo pipefail
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
$SUDO apt-get update
# ruby-dev + build-essential: several of fastlane's dependencies build
# native extensions.
$SUDO apt-get install -y ruby-full ruby-dev build-essential
ruby -v
# Only the first release pays the full gem build; afterwards this restores.
- name: Cache bundled gems
if: steps.key.outputs.configured == 'true'
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }}
restore-keys: |
${{ runner.os }}-gems-
- name: Install fastlane
if: steps.key.outputs.configured == 'true'
run: |
set -euo pipefail
gem install bundler --no-document
bundle config set --local path vendor/bundle
bundle install --jobs 4
bundle exec fastlane --version
- name: Upload to Play
if: steps.key.outputs.configured == 'true'
env:
SUPPLY_JSON_KEY: play-service-account.json
# supply is chatty on a TTY-less runner otherwise.
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: |
set -euo pipefail
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
# Absolute, because a lane body runs from fastlane/, not the
# workspace root — a relative path resolves against the wrong
# directory there and 2.17.1 died on exactly that.
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
bundle exec fastlane deploy \
aab:"$AAB" \
track:"$PLAY_TRACK" \
release_status:"$PLAY_RELEASE_STATUS" \
dry_run:"$PLAY_DRY_RUN"
echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track."
# The workspace is reused between runs on a self-hosted runner, so the
# credential must not outlive the job.
- name: Shred the service-account key
if: always()
run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json

View File

@@ -29,14 +29,30 @@ jobs:
- name: Run Renovate - name: Run Renovate
run: renovate run: renovate
env: env:
# Self-hosted Gitea, not github.com. # Renovate targets Codeberg (canonical) while still RUNNING on the
RENOVATE_PLATFORM: gitea # Gitea runner. Moving the job to Codeberg would put a repo-write
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1 # token on the contributor-facing runner, which is exactly what the
# Bot-account token (Gitea secret). Needs repo read/write + PR scope. # .forgejo/ vs .gitea/ split exists to prevent — so the token stays
# where the other secrets live and only the API calls cross over.
#
# Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the
# pinned image ships a distinct forgejo platform module.
RENOVATE_PLATFORM: forgejo
RENOVATE_ENDPOINT: https://codeberg.org/api/v1
# Codeberg bot-account token (Gitea secret). Needs repo read/write +
# PR scope on jlmakiola/calendula.
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
# Scope to this repo only — no org-wide autodiscovery. # Scope to this repo only — no org-wide autodiscovery.
RENOVATE_AUTODISCOVER: 'false' RENOVATE_AUTODISCOVER: 'false'
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]' RENOVATE_REPOSITORIES: '["jlmakiola/calendula"]'
# Commits/PRs authored as the bot, not a real maintainer. # Commits/PRs authored as the bot, not a real maintainer. This address
# must be a verified email on the Codeberg bot account, otherwise the
# commits show up unattributed there.
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>' RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
# Read-only github.com PAT (no scopes needed). Unaffected by the forge
# move — nearly every dependency is *released* on GitHub, and without
# this,
# changelog/release-note lookups hit the 60/h anonymous rate limit
# and PRs arrive with an empty "Release Notes" section.
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
LOG_LEVEL: info LOG_LEVEL: info

16
.gitignore vendored
View File

@@ -50,11 +50,27 @@ google-services.json
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Editor swap/backup files
*.swp
*.swo
*~
# F-Droid local artifacts (the pipeline generates them in CI) # F-Droid local artifacts (the pipeline generates them in CI)
/fdroid/ /fdroid/
# KSP # KSP
.ksp/ .ksp/
# Google Play Developer API service-account key. Reconstructed in CI from the
# PLAY_SERVICE_ACCOUNT_JSON secret and shredded afterwards — never committed.
/play-service-account.json
# fastlane (Play uploader only — see fastlane/Fastfile)
/fastlane/report.xml
/fastlane/README.md
/vendor/bundle/
/.bundle/
Gemfile.lock
# Claude Code # Claude Code
/CLAUDE.md /CLAUDE.md

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "floret-kit"] [submodule "floret-kit"]
path = floret-kit path = floret-kit
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git url = https://codeberg.org/jlmakiola/floret-kit.git

View File

@@ -17,9 +17,10 @@ re-inventing the calendar sync stack — leave that to DAVx5 and the system.
## Current Milestone ## Current Milestone
Milestones 1 (read, v1.0) and 2 (write support, v1.1v2.0.0 incl. reminder Milestones 1 (read, v1.0) and 2 (write support, v1.1v2.0.0 incl. reminder
delivery) are **complete** — v2.0.0 shipped 2026-06-11. Next is v3.0 delivery) are **complete** — v2.0.0 shipped 2026-06-11. Everything since is
(power-user features) plus an undecided "Locations & People" idea backlog; tracked as issues and milestones on Codeberg:
see `ROADMAP.md`. <https://codeberg.org/jlmakiola/calendula/milestones>. A milestone maps to its
`release/vX.Y.Z` branch.
## Stack ## Stack
@@ -27,10 +28,26 @@ Kotlin 2.3.21 (paired with KSP 2.3.9 — Kotlin 2.4.0 has no KSP release
yet, do not upgrade until one ships). Jetpack Compose + Material 3 yet, do not upgrade until one ships). Jetpack Compose + Material 3
Expressive 1.5.0-alpha21 (alpha is intentional — Expressive APIs only Expressive 1.5.0-alpha21 (alpha is intentional — Expressive APIs only
live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL
with Version Catalog. AGP 9.1.1, Gradle 9.5.1. JVM target 17. with Version Catalog. AGP 9.2.1, Gradle 9.5.1. JVM target 17 (exactly — AGP
requires it).
Android-only (minSdk 29, targetSdk 36). No iOS. No `INTERNET` permission — The shared Material 3 Expressive kit, **floret-kit**, is a git submodule wired
any feature that would need one is an explicit product decision first. in as a Gradle composite build, so it compiles from source rather than resolving
as a dependency.
## Constraints
- **Platform:** Android-only, Android 10+ (minSdk 29), targetSdk 36. No iOS.
- **Offline-first:** all data lives in `CalendarContract` — no app database, no
sync stack. No `INTERNET` permission; any feature needing one is an explicit
product decision first.
- **Privacy:** zero telemetry, zero analytics.
- **i18n:** German + English from day one; further languages come from community
translators via the self-hosted Weblate, which owns every `values-*` file.
- **Tests + CI from day one**, JVM-first.
- **Reproducible release builds**, so the official F-Droid repo can verify the
published binary against a from-source rebuild.
- **Licence:** MIT.
## Naming ## Naming
@@ -40,5 +57,9 @@ shows a stylized "1" on a slate squircle.
## Source ## Source
Hosted on self-hosted Gitea, released through self-hosted F-Droid repo on **Codeberg (`jlmakiola/calendula`) is canonical** — git, issues, PRs, tags and
Hetzner. Same infrastructure as `HouseHoldKeaper`. releases, plus contributor CI. The self-hosted Gitea instance is build
infrastructure only: it holds the signing key, runs the release pipeline, and
publishes the self-hosted F-Droid repo on Hetzner. Codeberg push-mirrors `main`
and tags to Gitea, where a bumped `versionName` triggers the release. Also
published to the official F-Droid repo. See `docs/RELEASING.md`.

View File

@@ -1,53 +0,0 @@
# Calendula — Requirements
See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
## V1 Scope (Variant "B") — shipped in full (v1.0.0, 2026-06-11)
- [x] Foundation & CI infrastructure — v0.1.0 (2026-06-08)
- [x] Data Layer over `CalendarContract`
- [x] Permission flow (`READ_CALENDAR`)
- [x] Month view (S1)
- [x] Week view (S2)
- [x] Day view (S3)
- [x] Event Detail Sheet (S4) — became a full screen, plus full event read (v0.6)
- [x] Multi-Calendar Filter (M3)
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
- [x] View-Switcher (M1)
- [x] Settings screen (M4)
- [x] Empty / no-permission / no-calendars states
- [x] German + English localization
- [x] Loading/Failure/Success states per screen (architectural pattern)
## V2 Scope — write support, shipped in full (v2.0.0, 2026-06-11)
- [x] Write foundation: `WRITE_CALENDAR`, read-only-calendar detection, delete (v1.1)
- [x] Create event: form, FAB, last-used calendar (v1.2; polish v1.2.1)
- [x] Edit event: shared form, scoped recurring writes, recurrence picker (v1.3)
- [x] Reminder notifications (v1.4) — **reversal of the original
"system handles reminders" assumption:** Calendula targets
sole-calendar-app users, so it posts reminder notifications itself
(Etar model), incl. `POST_NOTIFICATIONS` onboarding
- [x] Conflict dialog on save + store polish (v2.0)
- Quick-add — **cut from scope** (the prefilled form covers it)
- Calendar switching while editing — moved to v3 backlog
### Out of Scope (V3+)
- Home-screen widget
- Full-text search
- Tablet/foldable-specific layouts
- Locations & People ideas (contact picker, OSM autocomplete) — see
`ROADMAP.md` idea backlog, undecided
- iOS support (Android-only by design)
## Constraints
- **Tech stack:** Kotlin + Jetpack Compose + Material 3 Expressive, Hilt, DataStore
- **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.
- **Platform:** Android 10+ (API 29 minimum), Android 16 (API 36) target
- **Offline-first:** all data lives in `CalendarContract`; no app-side network
- **Privacy:** zero telemetry, no analytics
- **i18n:** German + English from day one
- **Tests + CI from day one**
- **License:** MIT

View File

@@ -1,587 +0,0 @@
# Calendula — Roadmap
## v0.x — Pre-Release
| Version | Milestone | Status |
|---|---|---|
| v0.1 | Foundation & CI | complete |
| v0.2 | Data Layer & Permission Flow | complete |
| v0.3 | Month + Week + Day views, view switcher | complete |
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
| v0.6 | Full event read — surface every readable field | complete |
| v1.0 | First public release — polish pass, F-Droid | complete |
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
Jump-to-date (the date-picker half of M2) was **cut from scope** and will not
ship. The "Today" half of M2 already shipped in v0.5 (drawer entry).
## v0.6 — Full event read
Round out the read-only model so a detail view shows everything the system
actually stores, before write support starts. Scope = `CalendarContract`
columns we don't yet read/display:
- **Reminders** (`VALARM`) — read `CalendarContract.Reminders`, list lead times
- **Status** — Confirmed / Tentative / Cancelled (cancelled shown struck-through)
- **Availability** (`TRANSP`) — Free / Busy chip
- **Attendee extras** — role (required / optional / organizer) + the user's own
`SELF_ATTENDEE_STATUS`
- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone
- **URL** — ~~tappable link card~~ **cut**: `CalendarContract` exposes no
`Events.URL` column (only `CUSTOM_APP_URI`, an originating-app deep-link).
URLs are instead surfaced by linkifying the description text
- **Access level / class** (private / confidential) — small chip (optional, trivial)
All of the above shipped in v0.6.0 (2026-06-11).
Deliberately out of v0.6:
- Recurrence exception / modified-occurrence badges — `Instances` already
resolves correct per-occurrence times for display; this only matters for
editing, so it folds into v2
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
(provider limitation, not our choice)
## v1.0 — First Public Release — shipped 2026-06-11
All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly
after v0.6 (full event read) plus the onboarding-screen polish pass.
### Polish backlog (pre-1.0)
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
## v2.0 — Write Support (complete, shipped 2026-06-11)
Delivered in four releasable slices (plan:
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
guide here, not a contract — scope per slice is decided as we go.
| Version | Milestone | Status |
|---|---|---|
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
| v2.0 | Conflict dialog, polish pass (store copy refresh, F-Droid screenshots), release | complete (shipped 2026-06-11) |
v2.0 scope was re-cut on 2026-06-11, after v1.4:
- **Occurrence edit** already shipped early, in v1.3.
- **Quick-add** is **cut from scope**: the full form already opens prefilled
(visible day, last-used calendar, optional fields hidden), so the sheet
would only save one screen transition while adding a second create-surface
to maintain. Revisit only if real-world feedback says creation feels heavy.
- **Calendar switching while editing** moves to the v3 backlog (sync-adapter
minefield: `CALENDAR_ID` is sync-adapter-owned, AOSP locks the field; an
honest implementation is copy+delete like Google Calendar, with sync-identity
and attendee side effects).
- **Conflict dialog** stays (plan 03, decision 5): on save, compare against
the row as it was when the form loaded; on external change, ask
overwrite / discard. Closes the silent-clobber gap on synced calendars.
## v1.4 — Reminder Notifications
**Essential**, not nice-to-have: Calendula targets users for whom it is their
*only* calendar app, so reminder delivery can't be delegated to Google/OEM
Calendar. The calendar provider schedules reminders and broadcasts
`android.intent.action.EVENT_REMINDER`, but it does **not** post the visible
notification — a calendar app must. We become that app (the Etar model).
Scope:
- Manifest-registered `BroadcastReceiver` for `EVENT_REMINDER`
(data scheme `content://com.android.calendar`) — wakes us at reminder time,
no foreground service.
- Read `CalendarContract.CalendarAlerts` / `Reminders`, filter to
`METHOD_ALERT` / `METHOD_DEFAULT` (skip `METHOD_EMAIL`); post on a dedicated
notification channel; tap opens event detail.
- `POST_NOTIFICATIONS` runtime permission (API 33+) — requested in onboarding.
- Onboarding step: (a) request `POST_NOTIFICATIONS`, (b) in-app reminders
toggle, **default ON**, with copy warning that a second calendar app with
notifications on will cause duplicate reminders. Mirrored into Settings
(reversible).
Deliberately deferred (add only if needed):
- Snooze / dismiss notification actions (Etar has them)
- Battery-optimization exemption prompt for delivery reliability
## v2.1 — Month event grid + drawer view tabs (shipped 2026-06-15)
- Month grid shows real events as continuous multi-day bars (not just dots)
- View section in the navigation drawer to switch Month / Week / Day
- Fix: text cursor no longer jumps in event text fields
## v2.2 — Tap-to-create + local calendar management (shipped 2026-06-16)
- Tap an empty slot in day/week → create form prefilled with that day + the
tapped hour (snapped to the hour, 1 h long)
- Local (device-only) calendar management in a full-screen editor from
Settings → Calendars: create / rename / recolor / delete, with name,
pastel-previewed colour, and description (stored in `CAL_SYNC1`)
- Synced calendars listed read-only, grouped by account, each with a
per-account "manage in source app" deep-link (resolved from the account's
authenticator — DAVx5/ICSx5/…) + an add-account shortcut
- Shared `InlineTextField` extracted to `ui.common` (event form + calendar
editor share one input style)
## v2.3 — Material 3 grouped-list redesign (shipped 2026-06-16)
A structural + visual pass adopting one shared blueprint (modelled on the ReFra
gallery app) across Settings, the calendar manager and the navigation drawer.
- Shared `ui/common/GroupedList.kt`: `CollapsingScaffold` (a `LargeTopAppBar`
whose title collapses on scroll) + `GroupedRow` (Position-based corner
grouping, press-animated corners, `selected` + `minHeight` knobs).
- Settings: category hub with About card on top and sliding sub-pages
(Appearance / New event form / Notifications); theme/week-start/language
pickers moved from `DropdownMenu` to OptionCard dialogs; token-based icon
chips; `ic_gitea.xml` for the About "Source" button.
- Calendar manager + drawer restyled to match; shared `CalendarColorChip`;
drawer scrolls as one with the active view highlighted.
- Cards use `surfaceContainerHigh` for readable contrast.
- Donate button on the About card deferred (target TBD).
---
# Backlog (theme-based, post-v2.1)
The old v3.0 / "daily-driver polish" / "Locations & People" lists are
consolidated here by theme. Within a group, **(in progress)** /
**(next)** mark what is being or about to be worked; everything else is an
approved-but-unscheduled idea unless tagged **(idea)** /
**(go/no-go)** / **(rejected)**. Order across groups is not a commitment.
## Near-term sequence (ranked, 2026-06-16)
The theme groups below are the full menu; this is the committed *order* for
the next stretch. Ranking favours finishing the current create/edit + calendar
arc before opening new fronts, then cheap-relative-to-value items and ones that
unblock a later item. Order is a plan, not a contract — revisit after each lands.
**Tier 1 — finish the current arc (create/edit + calendars)**
1. Tap-to-create in day/week *(shipped v2.2.0)* — prefilled create from an empty slot
2. Local calendar management + "manage in source app" deep-links *(shipped v2.2.0)*
3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
above)*
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
Tier 1's create/edit + calendars arc is effectively closed. **Duplicate event**
was deprioritised (2026-06-17) as low-importance and dropped to the bottom of
the sequence; the next item is now **Jump-to-date** (formerly Tier 2).
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
### Settings redesign & restructure *(shipped v2.3.0)*
The original scope below is kept as a record; the implementation expanded from a
sub-screen restructure into the shared grouped-list blueprint (see "v2.3" above).
The settings screen has grown into a flat vertical scroll of divider-separated
sections (Appearance, Event form, Notifications, Calendars, Language, About) and
will keep accreting rows (per-event-color defaults, default reminder, more
calendar entries are all queued). It needs structure before it gets unwieldy.
**Decided (2026-06-16): sub-screens**, not flat-but-carded. The top level
becomes a category list; each category opens its own destination. More
M3-idiomatic for a settings surface that will keep growing, and it mirrors the
existing Calendars row, which already navigates out to its own screen.
Structure — top-level settings list → category destinations:
- **Appearance** → theme, dynamic colour, week start
- **Event form** → the 6 default-field toggles + the hint text
- **Notifications** → reminders toggle (POST_NOTIFICATIONS flow stays)
- **Calendars** → already its own screen (`CalendarsScreen`); just becomes a
peer category row, no change to that screen
- **Language** → single control; keep as a top-level row that opens an
OptionCard directly (a whole sub-screen for one choice is overkill)
- **About** → kept inline on the top-level list as a card (read-only info,
not worth a navigation hop). Card layout, top → bottom:
- **Identity** — app logo + name "Calendula", with "by Jean-Luc Makiola"
as a subtitle beneath the name
- **Action buttons** (small, button-styled, sit in a row):
- **Source** — Gitea logo, opens the repo (`about_source_url`)
- **License** — opens the LICENSE file on Gitea
- **Donate** *(tentative)* — sits next to Source; target TBD (decide
before building: Liberapay / Ko-fi / Gitea sponsor / etc.)
- **Version** — small version number at the bottom of the card
Scope:
- **Navigation** — add the settings sub-screen destinations alongside the
existing settings/calendars routes in `CalendarHost`; back pops to the
settings list (mind the existing `BackHandler` that guards against falling
through to the activity).
- **Fix the dialog-pattern violation** — theme, week-start and language use
`DropdownMenu`; the project default is the full-width tonal OptionCard modal
(radio/dropdown/text-list dialogs are banned, see
`option-card-modal-style-default`). Migrate these selectors to OptionCard.
- **Visual pass** — top-level category rows with leading icons; consistent
spacing and row affordances aligned with the event-form card design system.
Out of scope (no new settings *features* here) — this is a structure + style
pass on the existing controls; new toggles ride in with their own features.
**Tier 2 — navigation & daily-driver completeness**
5. ~~Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap~~ *(done, v2.5.0)*
6. ~~Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget~~ *(done, v2.5.0)*
**Tier 3 — platform reach (depends on Tier 2)**
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; ~~quick-settings tile~~ *(done, v2.8.0 — "New event" QS tile)*
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
default reminder **+ per-calendar override**, bundled with battery-exemption
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
four surfaces all shipped: single-event share + whole-calendar backup
(export), open-`.ics`→form + whole-calendar bulk import (import). Closed the
device-local-calendar data-loss gap (#10/#11 merged here). Built as two
sequential branches: `feat/ics-export` (write side + UID-on-create precursor)
then `feat/ics-import` (parser, restore, dedup by UID). Import is
liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID`
/ guest lists it can't model. Plans:
`docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`.
11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)*
followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager
decision (snooze must re-fire an alarm).
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
the event model: attendees were already *read* (queried, mapped, shown on the
detail screen since v0.6) and are now *writable* from the form. An attendees
section on `EventEditScreen` / `EventForm` adds by typed email **or** the
no-permission contact picker, edits/removes rows, and sets role (required /
optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
caveat was **settled record-only** (Calendula never sends invites; the backend
decides delivery) — full sketch + decision in "Attendee editing" under Locations
& People below.
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
- Locations & People — the no-permission contact pickers already shipped (location v2.8.0, attendee email v2.8.0); what remains gated is OSM autocomplete (needs INTERNET)
- Move event to another calendar — sync-adapter minefield (copy+delete model)
**Bottom — deprioritised, not important**
- Duplicate event (detail action → prefilled create form) — moved here
2026-06-17; cheap but low value, pick up only if asked
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
Pulled in opportunistically, not sequenced.
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers —
full-text search, the "New event" Quick Settings tile, and the now-line all
shipped there. **No tier work is currently committed as next;** the remaining
candidates are unscheduled theme-group ideas (pinch-to-zoom, tablet/foldable,
accessibility pass) plus the gated go/no-go items.
## Navigation & views
- ~~Tap an empty slot in day/week → create form prefilled with that
date+time, snapped to the hour~~ **shipped v2.2.0** (long-press variant
not added — single tap covers it)
- Agenda view (fourth view: upcoming events grouped by day; also the
natural data source for a future widget)
- Jump to date — drawer date picker (un-cut from V1)
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
the view and shrinks the day cells; the badge stays week-view-only.
- Pinch-to-zoom time scale in day/week
- Tablet / foldable layouts *(was v3.0)*
- ~~Full-text search~~ **shipped v2.8.0** — for a daily driver with real event
history, finding an event is core completeness, not optional.
## Event editing & creation
- Duplicate event (detail action → prefilled create form)
- ~~**Per-event color**~~ *(shipped v2.4.0)*`EVENT_COLOR` / `EVENT_COLOR_KEY`
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
picker in the form, falling back to the calendar color when unset. Reused the
color-picker component and palette plumbing from local calendar management and
finished the create/edit theme.
## Calendars & accounts
- ~~Create / manage local (device-only) calendars~~ **shipped v2.2.0**
name + color + description; rename / recolor / delete the calendars the app
owns. Inserted under `ACCOUNT_TYPE_LOCAL` as a sync adapter; description in
`CAL_SYNC1`. Full-screen "Calendars" editor reached from Settings.
- ~~Per-calendar "manage in source app" deep-link~~ **shipped v2.2.0** — for
synced calendars, open the app the calendar actually came from based on
its `ACCOUNT_TYPE` (DAVx5 `bitfire.at.davdroid`, Google `com.google`,
…); fall back to system account/sync settings. Plus an "add account"
entry into system Accounts. Honest boundary for remote calendars.
- **Remote calendar create/edit** *(go/no-go)* — creating a CalDAV
collection (`MKCALENDAR`) or a Google calendar means an in-app sync
client: **INTERNET permission, credential storage, the full server
round-trip** — i.e. re-implementing DAVx5. DAVx5 exposes no public
intent to delegate the create to it. Cosmetic local edits (color/name)
to an existing synced row are possible but don't propagate to the server
and may be overwritten on next sync — not promised. Same explicit
go/no-go gate as the OSM/INTERNET item below.
- Move event to another calendar (copy+delete model with a consequences
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0**
device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no
backup. Settings → Calendars → Export writes every event to a user-chosen
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
UID). Closed the silent data-loss gap.
### Disable a calendar in-app *(captured 2026-06-25)*
A second, heavier visibility level **above** the existing per-view filter. Today
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
a calendar's *events* from the month/week/day/agenda views — the calendar itself
still clutters the drawer filter list, the event-form calendar picker, and the
import target picker. "Disable" removes a calendar from the app's surfaces
entirely; "hide" stays the lightweight, frequently-toggled control.
**Two-level model (both kept):**
- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the
drawer. Toggles events on/off in the views; the calendar stays listed
everywhere. Operates only over the *enabled* calendars.
- **Disabled** (new) — the calendar is gone from the app: not in the drawer
filter list, not in the event-form picker, not in the import picker, and its
events never appear (it's gated out of `instances()` like a hidden one). It
remains visible **only** in Settings → Calendars, where the enable/disable
toggle lives, so it can be brought back.
**Storage — app-side (DataStore), mirrors the hidden set.** Add
`disabledCalendarIds: Set<Long>` + `setDisabledCalendarIds(...)` to
`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`).
**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely
a Calendula-local preference, so other calendar apps are unaffected and the sync
adapters stay out of it (privacy-clean, reversible).
**Where the disabled set is applied:**
- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude
`calendarId ∈ (hidden disabled)` so a disabled calendar's events never show
and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw —
the screens that need everything still get everything.)
- `FilterViewModel.state` — drop disabled calendars from the drawer filter list
(you can't hide/show what's disabled).
- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create
into a calendar you've removed from the app. Handle the last-used-calendar
preselect falling on a now-disabled calendar (fall back to first enabled
writable).
- `ImportViewModel` — exclude disabled from the import target list.
- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists
disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle.
**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each
row on the existing `CalendarsScreen` (both the local and the synced/read-only
groups — disabling is an app-side view choice, independent of write access).
Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so
they're re-enableable. Follow the project dialog/list conventions (M3 grouped
list, `option-card-modal-style-default` if a confirm/selection surface is needed).
**Decided behaviour:**
- All calendars disabled → views show the existing empty state.
- A disabled calendar that still holds events: events simply vanish from
views/search until re-enabled (no data touched — it's a filter, not a delete).
- Deep links / notifications pointing at an event in a disabled calendar still
open its detail screen — detail is a direct id lookup, not an `instances()`
query, so disabling never strands an existing link.
## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)*
Two themes bundled because both are "make reminders trustworthy" — the core of
the "Calendula is your only calendar app" promise.
**Built in this slice (A + the safe half of B):** global timed default reminder
+ a **separate all-day default** (day-scale lead times) + per-calendar override
(timed events), applied on create with manual-edit / calendar-switch / all-day-
toggle handling; three pickers + per-calendar override list in Settings →
Notifications; battery-optimisation exemption row (status + system deep-link, no
extra permission). `resolveDefaultReminder` + prefs round-trips unit-tested.
Resolution model: all-day events use the all-day global default outright;
per-calendar overrides govern timed events only. Reviewed (8-angle), fixes
applied: form-reset state race, label-fn consolidation with the detail screen,
inline wrapper + single combined flow read.
**Deliberately deferred (documented decisions, not oversights):**
- *Absolute time-of-day for all-day reminders* — the all-day default is still
minutes-before-midnight (day-scale presets), not "9am the day before" (open
decision #2's richer half). Per-calendar all-day overrides also deferred.
- *Self-scheduled alarms* — kept the existing provider-broadcast architecture
(open decision #1). The battery exemption is the reliability lever; no
`AlarmManager`/`USE_EXACT_ALARM` subsystem was added.
- *Test-reminder diagnostic* and *battery prompt inside onboarding* — the
exemption lives only in Settings for now (onboarding flow untouched to keep
the change reviewable).
### A. Default reminders (global + per-calendar override)
**No provider backing.** `CalendarContract` has no column that auto-applies a
default reminder per calendar — Google's per-calendar defaults live server-side.
So both the global default *and* the per-calendar override are **app-side
preferences**, applied by us at event-insert time. We inherit nothing from the
synced calendar.
- **Storage (DataStore):**
- `defaultReminderMinutes: Int?` — global default; `null` = "no reminder".
- `defaultAllDayReminderMinutes: Int?` — separate all-day default (all-day
reminders are expressed as minutes before midnight / day-before-at-time, not
minutes before a start instant — they need their own value).
- `perCalendarReminderOverride: Map<Long, Int?>` — keyed by calendar id;
**absent key = inherit global**, explicit `null` = "no reminder for this
calendar". (Same for an all-day override map if we want per-calendar all-day.)
- **Apply on create:** a fresh event prefills its reminders list from
override-or-global for the preselected calendar. Changing the calendar in the
form re-applies the *new* calendar's default **only if the user hasn't manually
edited the reminders** — track a dirty flag, mirroring the per-event-color
reset pattern (v2.4).
- **Edit semantics:** defaults apply to **new events only**; never rewrite
reminders on existing events on open or on calendar-switch-during-edit.
- **Settings UI (Notifications sub-page):**
- Global default via OptionCard (None / at time of event / 5 / 10 / 15 / 30 min
/ 1 h / 1 day / custom), plus the separate all-day default.
- Per-calendar overrides: a row per writable calendar (in the Calendars screen
or a Notifications subsection), each opening the same OptionCard with a
leading **"Use global default"** option.
### B. Delivery reliability (exact alarms + battery)
The provider broadcasts `EVENT_REMINDER`, but on modern Android (Doze / OEM
battery managers) delivery can be silently delayed or dropped. v1.4 deferred this;
it directly undermines the feature's premise, so it rides in here.
- **Exact alarm — decision first:** trust the provider broadcast, or
self-schedule via `AlarmManager.setExactAndAllowWhileIdle` for reliability?
If we self-schedule, declare `USE_EXACT_ALARM` (API 33+, auto-granted for
calendar/alarm-category apps, F-Droid-clean) with a `SCHEDULE_EXACT_ALARM`
fallback for API 3132 (user-revocable → settings deep-link prompt).
- **Battery-optimization exemption:** a *soft, optional* prompt via
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (settings deep-link — never the
auto-grant intent), honest copy: "Android may delay reminders to save battery;
exempt Calendula for on-time delivery." Shown once after the existing
`POST_NOTIFICATIONS` onboarding step, reversible in Settings → Notifications.
- **Diagnostics:** a "send a test reminder in 1 minute" button in Notifications
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
are notorious for suppressing it).
### Decisions made (as shipped in v2.6.0)
1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
battery-optimisation exemption is the reliability lever (simplicity + battery
cost won over the exact-alarm subsystem).
2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
time-of-day ("9am the day before") deferred — see the deferred list above.
3. **Per-calendar overrides live in the Notifications sub-page** (override list),
governing timed events only.
### Round two
- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
## Sharing & interop
- ~~Share event as .ics + open/receive .ics into a prefilled create form~~
**shipped v2.7.0** — single-event share from detail; opening an `.ics` with one
event prefills the create form, many events opens a bulk import (dedup by UID)
- ~~ICS file import~~ **shipped v2.7.0** — covered by the open/receive `.ics`
flow above (single → form, many → bulk import)
## Platform & launchers
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
## Quality & reliability
- **Accessibility pass** — TalkBack content descriptions across all screens,
dynamic-type / large-font reflow, touch-target audit. Quality bar for an
F-Droid app; nothing tracks it yet.
- **Reminder delivery reliability** — exact alarms + battery-optimization
exemption; specced in the "Reminders — defaults & delivery reliability" slice
above (Tier 4 #9).
## Locations & People *(go/no-go, captured 2026-06-11)*
Beyond classic calendar-client scope; discussed, deliberately not planned
in detail yet:
- ~~**Contact address picker** for the location field via the system picker
(`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
the privacy story. The same no-permission mechanism was then reused for the
attendee email picker (v2.8.0).
- **OSM address autocomplete** in the location field (type "Brandenburger
Tor" → tap suggestion → resolved address inserted). Backend would be
Photon (Nominatim's public policy forbids autocomplete). **Requires the
INTERNET permission** — first dent in the "no network access" promise;
if built: opt-in (off by default), honest copy, configurable endpoint
for self-hosters, onboarding footnote + F-Droid copy reworded. This
trade-off is an explicit go/no-go decision before any work starts.
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
if the picker proves clunky.
- **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now
Tier 5 #12, high-importance; the no-permission typed-email path is not an
INTERNET/contacts call)*. See the "Attendee editing" sketch below.
### Attendee editing *(Tier 5 #12, opened 2026-06-22)*
The last read-only gap in the event model: attendees are read & shown on the
detail screen (since v0.6) but the form can't write them. Make guests editable.
- **Read side already done:** `Attendee` domain model + status/relationship/type
enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the
attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add
there.
- **Write side — SHIPPED (`feat/attendee-editing`, commit b0f34ff, 2026-06-22):**
`attendees` on `EventForm`; a Guests section in `EventEditScreen` rendered as an
inline grouped list — each guest a tonal card (avatar, name/email, tappable
Required/Optional role chip, remove), the trailing card an inline email field
(type → Done commits). Persisted by `reconcileAttendees` diffing the provider's
`CalendarContract.Attendees` rows on insert + dirty-checked update (new guests =
`RELATIONSHIP_ATTENDEE` / `STATUS_INVITED`; kept rows keep their status, only the
required/optional type updates; organizer/resource/no-email rows untouched).
Reminders restyled to the same grouped-list pattern in passing. Needs only the
existing `WRITE_CALENDAR` — no new permission.
- **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline
field is email-only (the quick path); the add row also has a **"from contacts"
button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI`
the system Contacts app returns a URI to the picked email row, queried for
`Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both
email and name in one tap. The result Intent grants temporary read access, so
**no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission
mechanism as the location address picker above. Self/organizer rows stay
non-editable.
- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable
calendars.** Calendula has no INTERNET and never sends an invitation itself; it
only writes `Attendees` rows. Whether a guest is notified is decided downstream:
local calendars notify no one (no sync); CalDAV/DAVx5 PUTs the `ATTENDEE` lines
and the *server* decides iMIP delivery; Google's sync adapter pushes the change
and Google decides (third-party attendee writes are historically unreliable
there). Editing is allowed on **any writable calendar** — not gated to local.
- **Honest, backend-aware copy is mandatory** (this is the whole point of the
decision): on a synced calendar show "Calendula doesn't send invitations —
your calendar account may email guests when it syncs"; on a local calendar
show "Stored on this device. No one is notified."
- Calendula must **not fabricate an ORGANIZER** or otherwise fake scheduling
state to coax a send — it writes the guest list faithfully and leaves
scheduling entirely to the backend.
- The optional "send an .ics invite via your email app" delegate (`ACTION_SEND`,
still no INTERNET) was considered and **deferred** — revisit only if users ask
to notify guests explicitly.
- **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups,
resource booking — all carry server round-trips or richer sync semantics.
## Consciously rejected
- Travel time / weather / smart suggestions (network, core-promise conflict)
- Natural-language quick entry (high effort, locale-fragile; the prefilled
form already covers fast entry)
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
- Drag & drop rescheduling in day/week — **rejected** (owner decision,
reaffirmed 2026-06-22): not wanted. Rescheduling stays via the edit form.

View File

@@ -1,177 +0,0 @@
# Calendula — Current State
*Last updated: 2026-06-22*
## Status
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`).
**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/
interop) arc is now done or in flight:
- v2.4.0 per-event colors (2026-06-17)
- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New
event" launcher shortcut (2026-06-17)
- v2.6.0 default reminders (global + per-calendar override, all-day default,
battery-exemption row) + system per-app language (2026-06-18)
- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export,
open/receive `.ics` (single → form, many → bulk import, dedup by UID)
(2026-06-18)
- v2.7.1v2.7.5 — crash-reporting + F-Droid reproducible-build hardening + fixes
**Next release `release/v2.8.0` (integration branch, not yet cut to main):**
holds crash reports via the public Codeberg tracker (MR !27) + reminder
snooze/dismiss notification actions (MR !28). Version bump to 2.8.0 happens at
release-cut.
## Progress
- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
- [x] Plan 01 written and executed — foundation lands (theme, icon, i18n, Hilt, DataStore, CI green)
- [x] Plan 02 written and executed — data layer + permission flow + debug screen
- [x] Month view (S1) — 6-week grid, event dots, today marker, swipe nav, three states (replaces debug screen)
- [x] Week view (S2) — time schedule with overlap-resolved lanes, all-day strip, swipe nav, three states
- [x] Day view (S3) — single-column slice reusing the week layout
- [x] View-switcher (M1) wired — cycles Month ↔ Week ↔ Day
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
- [~] Jump-to-date (M2) — **cut from scope**; "Today" half shipped in v0.5, date-picker dropped
- [x] Full event read (v0.6) — reminders, status, availability, access level,
attendee role + self-response, foreign timezone, and linkified description
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
URL field was cut — no `CalendarContract` column backs it.)
- [x] v1.1 write foundation — `WRITE_CALENDAR` (onboarding asks READ+WRITE,
only READ gates; contextual upgrade for v1.0 installs), read-only-calendar
detection (`CALENDAR_ACCESS_LEVEL``canModifyContents`, actions hidden for
WebCal/birthday calendars), delete from the detail screen (recurring:
"only this event" via cancelled exception / "all events in the series"),
repository + mapper tests
- [x] v1.2 create event — full-screen `EventEditScreen` (title, all-day,
M3 date/time pickers with duration-preserving start moves, writable-only
calendar picker preselecting the last-used calendar, location, description),
"+" FAB on all three views prefilled with the visible day, `insertEvent`
with provider-correct all-day normalisation (UTC midnights, exclusive end),
domain/mapper/repository tests
- [x] v1.3 edit event (shipped 2026-06-11) — `EventEditScreen` reused for
edit (detail-screen Edit action, `canModify`-gated, contextual WRITE
upgrade), dirty-checked partial `update` on the Events row (recurring:
series DTSTART moves by the user's delta, DURATION instead of DTEND),
reminder diff by minutes (kept rows keep their method), simple recurrence
picker (FREQ/INTERVAL/UNTIL/COUNT; complex RRULEs preserved verbatim and
shown humanized), `EventFormField.Recurrence` incl. settings default,
recurrence also available on create; domain/mapper/repository tests.
Review round 1: weekly BYDAY day-toggles in the custom picker ("every week
on Mon+Fri"). Review rounds 24: occurrence edit pulled forward from v2.0
and made three-way like delete ("this" = exception row via
`CONTENT_EXCEPTION_URI`, "this and following" = series split, "all" =
series update); delete equally three-way (truncation via RRULE UNTIL);
the edit-scope question moved to save time (Google model) — dirty
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
the "only this event" option
- [x] v1.4 reminder notifications (shipped 2026-06-11) — exported
`EVENT_REMINDER` receiver → `CalendarAlerts` (SCHEDULED & due) →
dedicated channel, tap opens detail (singleTop deep link); best-effort
FIRED marking; one-time onboarding step requesting `POST_NOTIFICATIONS`
with duplicate-reminders warning; Settings mirror. Provider only fires
`METHOD_ALERT` rows (AOSP-verified), so email reminders never reach us
- [x] v2.0 conflict dialog + store polish (shipped 2026-06-11 as v2.0.0) —
`EditSnapshot` compare on save (overwrite/discard; deleted → close),
quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy
refreshed, fastlane screenshots DE+EN captured on-device
- [x] v2.1 (shipped 2026-06-15) — month grid shows real events as
continuous multi-day bars; navigation-drawer View section
(Month/Week/Day); cursor-jump fix in event text fields
- [x] v2.2 (shipped 2026-06-16) — tap an empty slot in day/week to create
(prefilled with that day + tapped hour, snapped to the hour); local
calendar management in a full-screen editor from Settings →
Calendars: create/rename/recolor/delete device-only calendars
(`ACCOUNT_TYPE_LOCAL`, sync-adapter insert) with name, pastel-previewed
colour, and description (stored in `CAL_SYNC1`); synced calendars listed
read-only grouped by account with a per-account "manage in source app"
deep-link (resolved from the account's authenticator: DAVx5/ICSx5/…) and
an add-account shortcut. Shared `InlineTextField` extracted to `ui.common`
- [x] v2.3 settings/calendars/drawer redesign (shipped 2026-06-16) — adopted a
shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app
and extracted to `ui/common/GroupedList.kt` (`CollapsingScaffold` with a
`LargeTopAppBar` exit-until-collapsed title; `GroupedRow` with Position-based
corner grouping, press-animated corners, `selected` + `minHeight` knobs).
- Settings: category hub (About card on top → version mark at the foot) with
sliding sub-pages (Appearance / New event form / Notifications); token-
based icon chips; theme/week-start/language pickers migrated from
`DropdownMenu` to OptionCard dialogs. 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, pastelised calendar glyph).
- Navigation drawer: branded header, grouped View switcher (active view
highlighted via `secondaryContainer`), the filter list restyled to
grouped rows with a trailing checkbox; the whole drawer scrolls as one.
- Cards use `surfaceContainerHigh` for readable contrast against `surface`.
- Donate button on the About card deferred (target still TBD).
- [x] v2.4 per-event color (shipped 2026-06-17) — an optional "Color" field in
the event form. Read/render 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`
(extracted with the swatch row to `ui/common/ColorSwatchRow.kt`). Switching
calendars resets the choice (a key is account-scoped). A settings toggle
("Allow colors on unsupported calendars", off by default) extends the raw
path to synced calendars with no palette, with an honest "may not survive
sync" warning on the picker and in Settings. Color writes flow through
insert / dirty-checked update / occurrence-exception; mapper + form tests.
- [x] v2.5 (shipped 2026-06-17) — Agenda view (4th top-level view),
jump-to-date drawer date picker, two home-screen widgets (scrolling
"Upcoming" agenda + month grid), and a "New event" launcher long-press
shortcut
- [x] v2.6 (shipped 2026-06-18) — default reminders: global timed default +
separate all-day default + per-calendar override (timed), applied on create
with dirty-flag handling; three pickers + override list in Settings →
Notifications; battery-optimisation exemption row (status + system deep-link,
no new permission). Plus system per-app language (Android 13+) and an
immediate-effect fix for the in-app language picker
- [x] v2.7 (shipped 2026-06-18) — the `.ics` engine: share a single event as
`.ics` from the detail screen; back up local calendars (Settings → Calendars
→ Export) to a SAF file; open/receive an `.ics` — one event prefills the
create form, many events open a bulk import into a chosen calendar (dedup by
UID, skip-and-report unrepresentable VTIMEZONE / RECURRENCE-ID / guests).
Hand-rolled serializer/parser, zero deps. Plus all-day single-day UTC fix and
a widget R8 keep-rule crash fix
- [x] v2.7.1v2.7.5 (2026-06-21) — launch crash fix (listener before grant),
user-controlled crash reporting, widget loading-spinner R8 keep rule, and
F-Droid reproducible-build cleanups for the official repo
- [~] release/v2.8.0 (not yet cut) — crash reports via the public Codeberg
tracker (MR !27) + reminder snooze/dismiss notification actions (MR !28,
snooze self-schedules an exact alarm; primary delivery stays provider-broadcast)
## Next
1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the
merge-driven pipeline) once on-device review signs off
2. **Attendee editing** — the committed next feature (Tier 5 #12, high-
importance, opened 2026-06-22). Attendees are already read & shown on the
detail screen since v0.6; the gap is the write side — make guests editable
in `EventEditScreen` / `EventForm` (add by typed email, role, remove),
persisted by diffing `CalendarContract.Attendees`. No new permission for the
typed-email path. **Invitation behavior DECIDED 2026-06-22: record-only on all
writable calendars** — Calendula never sends (no INTERNET); honest backend-aware
copy ("your account may email guests when it syncs" on synced calendars, "no one
is notified" on local). Full sketch in `ROADMAP.md` → "Attendee editing".
3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar
create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week
numbers in month, full-text search, accessibility pass). Drag-and-drop
rescheduling is **rejected**.

View File

@@ -7,6 +7,380 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.18.0] — 2026-07-31
### Added
- A new event no longer always lasts an hour. **Settings → New event form →
Default duration** sets how long one opens, and each calendar may keep its own
length underneath — 8 hours for the calendar you keep work shifts in, 30
minutes for the one you book calls in. A calendar without its own length
follows the default, switching calendars mid-form re-stretches the event, and
setting an end time by hand keeps it. An event another app hands over with only
a start gets the default too; one that names its own end — an `.ics` file, a
duplicate — keeps that length. All-day events are unaffected ([#54]).
- Week and day view can be set to show more or less of the day at once, under
Settings → Views → Week & day → **Hour height**. **Fit whole day** sizes an
hour to your screen so all 24 hours are visible without scrolling — on a tall
phone the old fixed spacing showed only about half a day, so appointments
could sit below the fold all week. Compact and Comfortable are fixed steps
either side of the previous spacing, which stays the default. Both views share
the setting ([#56]).
- Week and day view can be **pinched** with two fingers to set the hour height
directly, anywhere between and beyond the named steps. The time under your
fingers stays put as it zooms, so you keep your place in the day. A pinched
height is remembered and appears as **Custom** in the Hour height setting, so
tapping a named step there takes you back to it ([#56]).
### Changed
- The date in the top bar is now the way to jump: tapping the month, week or day
title opens the same date picker the sidebar's **Jump to date** offers, seeded
on whatever the bar is naming. A drop-down caret marks it as tappable. The
sidebar entry stays where it is ([#57]).
- A reminder in the status bar is now Calendula's own mark — the calendar with
the bloom — instead of the generic calendar glyph it shared with the system's
date surfaces and every other calendar app. The status bar draws that icon as
a plain silhouette, so its shape is the only thing that could tell them apart
([#83]).
- Event blocks now only draw text they can draw whole. One too short for a full
line shows no title rather than a sliced one, a block that cannot fit both its
title and its time keeps the title, and a block too narrow to hold more than a
syllable stays on one ellipsised line instead of stacking letters down the
block. Tapping and the spoken description are unchanged ([#56]).
- Calendar colours are reworked so text on an event is always readable. They
were shaped by a brightness setting that does not match what the eye sees, so
whether a block got dark or light text depended on which hue you happened to
pick — an orange calendar took dark text while a red one beside it took light
— and colours landing in between were hard to read either way. Each colour now
keeps its hue and is moved clear of that middle: most become deep blocks with
light text, while naturally pale colours such as yellow stay pale and take
dark text, rather than being forced into a muddy brown. Dots, stripes and
icons are tuned separately from blocks, so they stay visible against the
background instead of sharing a colour meant to sit behind text. The setting
is now called **Harmonise calendar colours**; turning it off still shows the
raw colours from your calendar source ([#21], [#36]).
### Fixed
- The back gesture closes the sidebar instead of the app. With the drawer open,
swiping back left Calendula altogether rather than putting the sidebar away
([#114]).
- The sidebar lines up. "Calendula", "View" and "Calendars" now share the left
edge of the rows under them, and the view, jump-to-date, Settings and calendar
rows sit on one vertical axis instead of each icon finding its own — the same
alignment the Settings screens use ([#114]).
- The status- and navigation-bar icons follow Calendula's own light/dark choice.
Setting the app dark while the system stayed light — or the other way round —
left the clock and battery drawn for the system's theme, so they could sit
near-invisible against the app's own bar ([#70]).
## [2.17.1] — 2026-07-30
### Added
- Settings → Calendars now says what is different about a calendar instead of
leaving you to guess. Ones you can only view — a subscribed calendar, a
calendar shared with you read-only — are marked **Read-only** ([#76]).
- Calendars your device isn't syncing are marked **Not synced**, moved to the
bottom of their account and left without a switch. None of their events are on
the device, so the switch they used to have could not have shown you anything
— the calendar simply looked broken. They are no longer offered when you pick
a calendar for a new or an imported event either: an event saved there would
never reach the account. Whether an account syncs a calendar stays that
account's own app's decision ([#78]).
- The birthday and anniversary calendars Calendula fills from your contacts are
marked **Filled from your contacts**, which is why they can't be picked for a
new event: anything you put there would be removed again on the next sync.
Deleting one is held back while special dates are switched on — Calendula
would simply create it again — and the calendar's editor says so; turn the
feature off under Settings → Special dates and the delete works as usual
([#76]).
- The calendar picker in the event form and in the .ics import screen now ends
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
those marks then explain why a calendar isn't offered ([#76]).
- The agenda widget's text size is yours to set. **Settings → Widgets & tiles →
Agenda widget size** offers Small, Medium, Large and Extra large, replacing the guess
the widget used to make from its own measurements. Small is what it looks like
today, so nothing changes until you turn it up ([#51]).
- A repeating event now shows you its next few dates, not just a description of
the rule. Both the preset list and the custom recurrence picker carry a
**Next:** line — "Next: 30 Jul, 6 Aug, 13 Aug" — under the rule they would
save. A phrase like "monthly" on the 31st, or "every 2 weeks on Mon & Fri",
can mean something other than it sounds like, and only the dates say so. A
rule that can never fire says that instead ([#69]).
- The event visibility options now say who they affect. **Public**, **Private**
and **Confidential** each carry a line about what other people on a shared
calendar see — the part the four words on their own leave out ([#69]).
### Changed
- **Settings has been reorganised so each setting sits where you would look for
it.** One long undifferentiated list is now three labelled groups — Look &
behaviour, Data, and App — whose rows open sub-screens and say what they do
rather than only naming themselves. Appearance, Views, New event form,
Notifications and the new Widgets & tiles are separate screens now, so the
settings for a calendar view are no longer mixed in with the ones for the
app's colours or for the home-screen widgets ([#69]).
- Settings that are hard to picture from their name now show you what they do.
The week-start picker rearranges a real month grid as you choose, the
past-events setting previews a sample agenda day for Show, Dim and Hide, the
font pickers set a specimen line in the face you are choosing, and the Agenda
range options carry the dates each one actually covers. Options that follow
the system additionally name which way they currently fall ([#69]).
- **Backup & restore** is now its own Settings entry instead of living inside
the calendar manager, where it was easy to miss — keeping a copy of your
calendars is a different question from which calendars you have. The calendar
manager keeps a row pointing to it, and nothing about how backup or automatic
backup works has changed ([#69]).
- Calendula's source code now lives on **Codeberg**, where its issues already
were. The **Source code** and **License** links in Settings → About point
there, so reporting a bug and reading the code no longer land on two different
sites. Nothing about the app itself changes, and the F-Droid repository is
unaffected.
### Fixed
- A month-grid widget stays a month grid, and draws all seven days again. Since
2.16.0 a placed month widget could redraw itself as the agenda widget a little
after any change to your events, and could draw only about four day columns
with the last one cut off part-way through. Both came from the release build
merging the two widgets into a single class, so Android could no longer tell
which of them a widget on your home screen was — and the month grid was handed
the wrong widget's measurements to lay its columns out against ([#89], [#103]).
- The back gesture on **Settings → Views** returns to Settings instead of
leaving Settings altogether and dropping you on the calendar. Special dates
did the same ([#81]).
- The dots standing in for the events that didn't fit a day in the month view
now dim with everything else when **Dim completed events** is on. A past day
with four or more events kept its last events at full strength while the rest
faded ([#79]).
- Two accounts that happen to share a name — a Google account and a DAVx5
account for the same address, say — are no longer merged into one group.
They were listed together in Settings → Calendars and in the drawer's filter,
which also meant the group's source icon and its "manage in app" button could
send you to the wrong app, "toggle all" spanned both accounts at once, and
collapsing one collapsed the other. Where a name really is shared, each group
now names the app it comes from ([#77]).
- In the month view's **Split** style, the new-event button now starts on the
day you have selected. It always started on today, whichever day was selected
and listed below the grid ([#87]).
- Search results now show an all-day event's real date. West of UTC — anywhere in
the Americas, say — a search hit was dated one day early, disagreeing with the
day the month, week and agenda views file the same event under ([#82]).
- Reminders no longer depend on Android telling Calendula when they are due.
Calendula now works out each reminder's time itself and sets its own alarm for
it. On some phones — Samsung's among them — the system's calendar storage never
sends the signal a calendar app is meant to wake up on, and no amount of
battery or notification settings helps: the reminder is simply never announced.
None of that is visible from inside an app that waits to be told, which is why
it took a second pass to find ([#75]).
Reminders also survive things that used to lose them quietly. After a restart
or an app update Calendula re-arms its alarms, and a reminder whose moment
passed while the phone was off still arrives, as long as the event has not
ended yet.
- All-day reminders now arrive at the time you chose in **Settings →
Notifications**, on every occurrence. A yearly birthday could drift an hour
either way depending on daylight saving, and all-day reminders on calendars
from an account fired in the middle of the night instead of in the morning
([#75]).
- Reminders now arrive for every calendar you have switched on. A calendar that
was hidden at system level — switched off in another calendar app, or never
switched on after being added — still showed its events and listed their
reminders in Calendula, but never notified: Android only schedules reminder
alarms for calendars marked visible, and Calendula kept its own separate
on/off list that had no say in it. There is now one switch: **Settings →
Calendars** turns a calendar on or off for the whole device, so what you see
and what reminds you can no longer disagree ([#75]).
Calendars you had switched off in Calendula are switched off here too on first
launch. Calendars that were already off — hidden in another calendar app, or
never switched on after being added — stay off, and Calendula says so once
rather than quietly switching them on for every app on your device; you can
turn any of them back on in Settings → Calendars.
If you gave Calendula read-only access to your calendars, the switch still
works: your choice is kept in the app until it can be written.
The drawer's filter is unchanged and still app-only: hiding a calendar there
tidies your view without silencing its reminders.
## [2.16.0] — 2026-07-24
### Added
- Choose how the month view is laid out. A new **Month view style** setting
(Settings → Views) offers three ways to read a month, each shown with a
preview of the layout it produces:
- **Pages** — what you have today: one month at a time, swiped sideways.
- **Continuous** — scroll up and down through the weeks without a break
between months. Because the weeks run on unbroken, no month is cut off and
no day appears twice, where paging repeats a boundary week at the end of one
month and the start of the next. The 1st of each month names itself so you
always know where you are, and the title bar keeps up as you scroll ([#38]).
- **Split** — a compact grid showing coloured dots for the days that have
something on them, with the day you tap listed in full underneath. Tap the
date above the list to open the whole day ([#53]).
The Agenda view is untouched by this and stays available in all three styles —
the split layout lists a single day, while Agenda remains a rolling multi-day
window with its own range settings.
- Give an event its own time zone. A new **Time zone** field (under "more
fields" in the event form) pins an event to a specific zone, so a call set for
8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps
tracking that zone across daylight-saving changes instead of drifting an hour.
The form edits the event in its own zone and shows the local equivalent under
the times ("2:00 PM 3:00 PM your time"); the event's details keep your local
time first and note the original beneath it, so both are always clear. Pick a
zone from a full-screen picker with your device zone and recent choices on top,
searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation
("CEST") to gather every matching zone at once. All-day events stay
date-anchored and carry no zone, as before ([#31]).
- Put the "jump to today" button in the toolbar. A new **Today button in
toolbar** setting (Settings → Appearance, off by default) swaps the floating
button that fades into the corner while you're away from today for a permanent
today icon in the top bar — always there, on today or not, matching the
familiar calendar-app pattern. Leave it off to keep the floating button as
before ([#60]).
- Choose what Calendula calls itself on your home screen. A new **App name**
setting (Settings → Appearance) switches the launcher label between
**Calendula** and **Calendar**, for launchers that can't rename apps
themselves. Pick from a full-screen chooser that previews both names as
launcher marks; the change applies at once ([#44]).
- Calendula now speaks **Arabic**, laid out right-to-left, and its French and
Italian translations have been brought up to date — thanks to the community
translators on Weblate. Pick a language under Settings → Language (or leave it
on the system default).
### Changed
- Dates in the Month, Week and Day title bars now follow your language and
region instead of one hardcoded layout. Every date was rendered in a fixed
German-style order with a trailing dot on the day number, whatever your
settings: US English showed "Fri, 17. Jul 2026" where it should read
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
disagreed about the same date. All four views now share one formatter, and the
day/month order, the separators and the ordinal all come from your locale —
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
each correct for where you are ([#60]).
- The title bar drops the year while you're in the current one — "July" rather
than "July 2026". The year reappears the moment you page out of the current
year, which is when it tells you something you didn't already know.
- The Week view's title now names the month instead of spelling out the day range.
"24. Jun 31. Jun" restated the day numbers already printed in the column
headers right below it, in the widest string in the bar. A week that straddles
two months keeps the outgoing month until it is fully gone ([#60]).
- The custom recurrence picker has been redesigned and tightened up. As you
build a rule — "every 2 weeks on Mon & Wed, until a date" — the live summary
now describes exactly what will be saved rather than a near-copy that could
drift from it, the amount fields accept being left blank (reading as their
shown default instead of greying out OK), and the read-out no longer jumps
around as you tap weekdays ([#42]).
### Fixed
- An all-day event no longer shows up again the day after it happened. In time
zones east of UTC, an all-day event — a birthday, say — set for one day also
appeared under the *next* day's heading in the Agenda (and the agenda widget),
because all-day events are anchored to UTC midnight and the following day's
window reached back across that boundary and pulled the event forward onto
"today". Each all-day event now lists only on the day it actually falls on
([#65]).
- A multi-day event now shows under every day it spans in the Agenda, not just
its first day, so a trip or a multi-day booking appears on each day it covers.
- The "Upcoming" agenda widget now scales its text and rows to the size you give
it. Previously it was laid out once for the smallest size and simply stretched
when enlarged, so the text stayed small no matter how big you made the widget.
Now a bigger widget gets bigger, more readable type and roomier rows, while the
default size looks exactly as before — no new setting; it follows the size you
already chose ([#51]).
- Calendula now appears under other apps' "Add to calendar" / "Save to calendar"
actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event"
intent with the singular `vnd.android.cursor.item/event` type, which Calendula
didn't advertise — so it was left out of the chooser, and if it was your only
calendar app the save silently did nothing. It now accepts that form, plus the
`INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]).
- Opening a `.ics`/`.vcs` file now works even when another app hands it over
mislabelled as a generic download (`application/octet-stream`), as some mail
clients, browsers and file managers do — Calendula recognises it by its file
extension instead of relying on the declared type ([#74]).
- A recurrence end date no longer lands a day late. West of UTC, setting a rule
to end "until" a given day could save and show the day after the one picked;
the end date now reads back as chosen ([#42]).
- The status- and navigation-bar icons stay legible over full-screen pickers in
dark theme. They could render dark-on-dark — a near-invisible black clock
against the dark picker — instead of switching to light ([#70]).
## [2.15.0] — 2026-07-15
### Added
- Show raw calendar colours. Calendula normally softens each calendar and event
colour toward a theme-fitting pastel so harsh sync colours read well on both
light and dark; a new **Soften calendar colours** setting (Settings → Design,
on by default) lets you turn that off and paint the exact colours your calendar
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
@leonp5 for the report ([#36]).
- Readable titles on dark event colours. An event bar's title now shows in white
on a dark colour and near-black on a light one, chosen automatically from the
colour's brightness, so a deep blue or purple event is legible at a glance in
the busy Week and Month views instead of dark-on-dark. This applies whether or
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
- A custom snooze duration. The **Snooze duration** setting (Settings →
Notifications) gains a **Custom…** option next to the minute presets: pick any
amount and switch between minutes and hours, so a snoozed reminder comes back
after exactly the delay you want instead of only a preset one ([#40]).
- Move an event to another calendar. When editing an existing event, the
calendar row is now tappable — pick a different calendar and saving moves the
event across, instead of having to delete it and recreate it elsewhere.
Recurring series move as a whole, keeping their individually-edited and
cancelled occurrences, and any reminders and guests come along too. A calendar
can't simply be reassigned underneath an event, so Calendula recreates it on
the target and removes the original — the same approach other calendar apps
take. Thanks to @prismplex for the suggestion ([#39]).
- Open an event straight into the edit form from another app. Calendula already
answered the "new event" and "open this event" hand-offs from other apps and
widgets; it now also answers the "edit this event" one, so an assistant, task
app, or widget can send an existing event to Calendula and land on its edit
screen rather than the read-only details. A hand-off with no event attached
opens the same prefilled create form as "new event". Calendula also recognises
a couple more file labels the same calendar data arrives under (`.vcs`
vCalendar files and the `application/ics` type), so opening or sharing those
into Calendula works too.
- Keep today at the top of the agenda. A new **Always show today** setting
(Settings → Agenda, on by default) anchors today as the first entry in both the
Agenda screen and its home-screen widget even once nothing is left today —
under today's header a "No more events today" note appears — so the first
events you see are clearly today's rather than a future day's. Turn it off to
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
- Duplicate an event. The event details now carry a **Duplicate** action that
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
a one-off like a shift or an appointment can be recreated by just changing the
day and time instead of re-typing every field. The copy keeps the original's
time, and its title, location, notes, colour, guests and reminders come along;
it's saved as its own single event (any repeat is left off — add one in the
editor if you want it). Duplicate works from read-only calendars too, dropping
the copy into a writable one. Thanks to @internet-rando for the suggestion
([#52]).
- French and Polish, in early form. Calendula has started speaking French and
Polish, both contributed as community translations through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
They are partway there, so untranslated parts still show in English until they
fill out — you can already pick either under Settings → Language or in Android's
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
(Polish) for getting them started; help finishing them is very welcome.
### Fixed
- Reminders for events on another day no longer read as if they were today. A
reminder fired ahead of time — say, the day before — used to show only the
event's time, making it look like it was happening now. The notification now
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
week, or the date for anything further out. Thanks to @moonj for the report
([#46]).
- Agenda dates now read in your locale's format. The agenda's range bar and its
day headers used a fixed day-month-year layout — and the range span even mixed
two orders (e.g. "15 Jul Aug 13, 2026") — instead of following your language's
conventions. Dates across the agenda and its widget now use your locale's own
field order, matching the rest of the app. The range bar also no longer repeats
the range's name from the selector button beside it, showing just the dates.
- Calendar gutters line up with the menu button. The Month view's week-number
column, and the Week and Day views' hour labels, sat a few pixels left of the
hamburger menu above them; they now line up with it. In Month view the day
cells also sit squarely under their weekday letters.
## [2.14.1] — 2026-07-13 ## [2.14.1] — 2026-07-13
### Fixed ### Fixed
@@ -908,11 +1282,44 @@ automatically, with zero telemetry and no internet permission.
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25 [#25]: https://codeberg.org/jlmakiola/calendula/issues/25
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27 [#27]: https://codeberg.org/jlmakiola/calendula/issues/27
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29 [#29]: https://codeberg.org/jlmakiola/calendula/issues/29
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30 [#30]: https://codeberg.org/jlmakiola/calendula/issues/30
[#31]: https://codeberg.org/jlmakiola/calendula/issues/31
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32 [#32]: https://codeberg.org/jlmakiola/calendula/issues/32
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33 [#33]: https://codeberg.org/jlmakiola/calendula/issues/33
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34
[#36]: https://codeberg.org/jlmakiola/calendula/issues/36
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37 [#37]: https://codeberg.org/jlmakiola/calendula/issues/37
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47 [#47]: https://codeberg.org/jlmakiola/calendula/issues/47
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48 [#48]: https://codeberg.org/jlmakiola/calendula/issues/48
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49 [#49]: https://codeberg.org/jlmakiola/calendula/issues/49
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
[#38]: https://codeberg.org/jlmakiola/calendula/issues/38
[#53]: https://codeberg.org/jlmakiola/calendula/issues/53
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
[#74]: https://codeberg.org/jlmakiola/calendula/issues/74
[#42]: https://codeberg.org/jlmakiola/calendula/issues/42
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
[#77]: https://codeberg.org/jlmakiola/calendula/issues/77
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
[#83]: https://codeberg.org/jlmakiola/calendula/issues/83
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
[#54]: https://codeberg.org/jlmakiola/calendula/issues/54
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114

184
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,184 @@
# Contributing to Calendula
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
Android's `CalendarContract` — no app database, no sync stack, no network access.
That constraint shapes most review comments, so
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write
code. This file is the practical how.
**[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** —
issues, pull requests, releases. The self-hosted Gitea instance referenced in the
release docs is build infrastructure only; there is nothing to contribute there.
Be decent to the people you meet in the tracker.
## Start with an issue
| You want to | Do this |
|---|---|
| Add a feature | **Open an issue first** and wait for a go-ahead |
| Fix a bug | Open an issue, then a pull request |
| Fix a typo, a comment, or docs | Just open the pull request |
| Add or fix a translation | **Don't** — [use Weblate](#translations) |
Features get an opinion before they get code: whether Calendula should do a
thing at all is the one decision a patch can't make. A feature PR that arrives
without a discussed issue may be closed unmerged even when the code is good —
please don't spend a weekend on one first.
Bugs are more straightforward, but still start with an issue: it's what carries
the milestone and gives the changelog something to link.
Issue templates cover bug, crash, feature and question. For a crash, let the app
do the work — **Settings → Report a problem**, or the prompt shown after a crash,
captures the stack trace and prefills the form. The report contains app, Android
and device versions plus the trace; no calendar content, no personal data.
## Which branch to target
Calendula releases by merging a version bump into `main`, so `main` is a release
trigger rather than a staging area. Work is assembled on release branches first.
Once your issue has a milestone, that milestone names your branch:
| Milestone | Target branch |
|---|---|
| `2.18.0` | `release/v2.18.0` |
Every milestone has a matching branch. If it's somehow missing, target `main` and
mention it in the PR — it will be retargeted. Don't pick an older release branch:
they're kept after shipping, so the newest one isn't necessarily yours.
## Translations
**Never edit a `values-*/strings.xml` file in a pull request** — German included.
Translations are owned by a self-hosted Weblate that writes to this repository
directly, and a hand-edit is overwritten on the next sync.
**[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
picks it up and offers it to translators. Partial translations are expected and
fine — missing keys are informational. Stale and orphaned keys are not, so run
```sh
python3 scripts/check_translations.py
```
before pushing. It reports those more clearly than lint's `MissingTranslation`
does.
## Build & test
```sh
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
```
The `floret-kit` submodule is a composite build compiled from source. An
existing clone needs `git submodule update --init --recursive`, or nothing
resolves.
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. Set
`JAVA_HOME` if your default differs.
- **Android SDK** — platform 37 (`compileSdk`) and build-tools 36.0.0, located
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
go the `local.properties` route the included build needs its own copy at
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
easier path.
The Gradle wrapper is checked in, so no system Gradle is needed.
```sh
./gradlew lint test assembleDebug # roughly what CI runs
```
A single test class, or a pattern:
```sh
./gradlew testDebugUnitTest --tests "de.jeanlucmakiola.calendula.domain.SimpleRecurrenceTest"
./gradlew testDebugUnitTest --tests "*SimpleRecurrence*"
```
CI reports one `CI` check per pull request: `lintDebug`, `testDebugUnitTest`,
`assembleDebug`, and a Trivy scan. Pull requests touching only docs, F-Droid
metadata or the licence skip the Android build and go green quickly. More detail
in [`docs/BUILDING.md`](docs/BUILDING.md).
## The rules
These are the ones that turn into review comments.
1. **No network.** Calendula holds no `INTERNET` permission, and that's a
feature rather than an oversight. Anything that would need one is a product
decision before it's a patch — the crash reporter deliberately opens a
prefilled web issue instead of posting anything itself.
2. **The provider is the only database.** No Room, no cache, no local mirror of
events. `CalendarContract` is the single source of truth, which is also why
externally synced changes work for free.
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
views recompose from fresh provider state. Hand-patching a list after saving
appears to work, then quietly diverges from what the provider actually stored.
4. **`domain/` has no Android imports.** Models, validation, recurrence
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
remain JVM-testable.
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
through `ColumnReader` instead of cursors. Instrumented tests are a last
resort, not a default.
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
DURATION, exception URIs, series splits), save-conflict detection and reminder
delivery (post-before-mark) follow provider-driven rules that are documented
in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) and are not guessable from
the code alone.
7. **Don't break reproducible builds.** `vcsInfo`, `dependenciesInfo` and the AGP
metadata block are disabled on purpose so the official F-Droid repo can verify
our binary against a from-source rebuild.
`scripts/check_reproducible_release.sh` runs on every pull request, including
docs-only ones.
## UI conventions
Material 3 Expressive throughout, built from the system's own tokens and
components — colour-scheme tokens rather than hardcoded colours, `ListItem` for
settings rows.
**Selection pickers are full-screen.** Every browse-style "choose one" surface
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
extra action passes it through the picker's `actions` slot. The exception is the
recurring-scope chooser (*this / this and following / all*), which stays a
compact dialog — a two- or three-option decision reads better as a popup than as
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
or text-list dialogs aren't used at all.
Shared UI machinery lives in the `floret-kit` submodule and has
[its own contributing guide](https://codeberg.org/jlmakiola/floret-kit/src/branch/main/CONTRIBUTING.md);
changing it means a pull request against that repository plus a submodule bump
here.
## Commits & pull requests
Conventional commits, scoped to the area you touched:
```
fix(calendars): keep an event's own calendar when it is switched off
feat(month): pull-to-expand the split view (#38)
docs(architecture): record what the second review pass changed
```
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
`revert`. Reference the issue in the subject or the body. Keep commits small —
small commits revert cleanly, which matters more here than a tidy history.
If your change is user-visible, add an entry under `## [Unreleased]` in
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
what changed *for the person using the app*, and why, not what changed in the
code. Link the issue and add its reference at the bottom of the file. It may get
reworded when the release is cut, so don't agonise over it.
Please don't commit planning or design documents. Code, tests, architecture notes
and the changelog land; the reasoning belongs in the commit message and the
issue.
## Licence
Calendula is [MIT](LICENSE). By contributing you agree your changes ship under
the same licence.

10
Gemfile Normal file
View File

@@ -0,0 +1,10 @@
source "https://rubygems.org"
# fastlane is used ONLY to upload the release bundle to Google Play
# (see fastlane/Fastfile). It is not part of the build or the signing path, so
# it never runs on a PR — only in release.yaml's `play` job.
#
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
# committed on purpose: this resolves an uploader's transitive deps, not the
# app's, and none of it affects the reproducible release build.
gem "fastlane", "2.237.0"

103
README.md
View File

@@ -8,7 +8,7 @@
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p> Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
<p> <p>
<a href="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions"><img src="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a> <a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+"> <img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose"> <img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive"> <img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
@@ -18,15 +18,18 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
<p> <p>
<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://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>
&nbsp; &nbsp;
<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>
&nbsp;
<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> <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> </p>
<p> <p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="16%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="16%" alt="Month view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png" width="16%" alt="Day view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-edit.png" width="19%" alt="Event form">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="16%" alt="Event detail">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding"> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png" width="16%" alt="Agenda view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="16%" alt="Reminder onboarding">
</p> </p>
</div> </div>
@@ -68,24 +71,38 @@ database, no sync stack reinvented.
- Real Material 3 Expressive throughout — dynamic color (Android 12+), - Real Material 3 Expressive throughout — dynamic color (Android 12+),
expressive motion and shapes, light/dark theme expressive motion and shapes, light/dark theme
- German and English UI, per-app language setting — and [open to community - English and German UI plus community translations (Spanish, French, Italian,
translations](#-translations) Polish, and more in progress), per-app language setting — and [open to more
languages](#-translations)
- **Zero telemetry, zero analytics, no internet permission** — your data - **Zero telemetry, zero analytics, no internet permission** — your data
never leaves the device never leaves the device
## 📦 Install ## 📦 Install
Pick whichever channel you already use — they all install the same app:
| Channel | Updates | Notes |
| --- | --- | --- |
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium |
| [Google Play](#google-play-coming-soon) | — | Coming soon |
| [Build from source](#build-from-source) | Whenever you build | Full control |
### F-Droid (recommended) ### F-Droid (recommended)
Calendula is on the **official [F-Droid](https://f-droid.org) repository** Calendula is on the **official [F-Droid](https://f-droid.org) repository**
just search for **Calendula** in any F-Droid client, or just search for **Calendula** in any F-Droid client, or
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/). [install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
### Self-hosted repo (latest builds) F-Droid rebuilds from source on its own schedule, so a new version usually
shows up there a few days after release.
New versions are built, signed, and published to a self-hosted repository the ### Self-hosted F-Droid repo (fastest updates)
moment each tag lands — usually a few days ahead of the official repo, which
rebuilds on F-Droid's own schedule. Add it for the freshest builds: Every release is built, signed, and published to a self-hosted F-Droid
repository as part of the release pipeline, so it lands there first. Add it once
and your F-Droid client handles updates from then on:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the 1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone): link below on your phone):
@@ -100,19 +117,71 @@ rebuilds on F-Droid's own schedule. Add it for the freshest builds:
2. Refresh, search for **Calendula**, install. 2. Refresh, search for **Calendula**, install.
Both channels share the same signing key, so you can switch between them ### Codeberg release / Obtainium
without reinstalling. Or build from source — see below.
If you'd rather not use F-Droid at all, every release is also published on
**[Codeberg](https://codeberg.org/jlmakiola/calendula/releases)** with the
signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — download
and install it directly.
For automatic updates from that channel, use
**[Obtainium](https://github.com/ImranR98/Obtainium)** — on the phone,
**[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula)**,
or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
one appears.
### Google Play (coming soon)
Calendula is on its way to Google Play as an additional channel. It isn't live
yet — this section gets a link once it is. Play builds will be signed with
Google's key rather than mine, so switching between Play and any other channel
will require an uninstall.
> **Testers wanted.** Play requires a round of closed testing before the app can
> go public, and I'm still looking for testers. If you'd like to help, email
> **[business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)** with the
> Google account address you want to use — that address is what I need to add you
> to the closed test.
### Build from source
The build is a plain Gradle build with no proprietary dependencies — see
**[docs/BUILDING.md](docs/BUILDING.md)** (note the `floret-kit` submodule).
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
the same signing key, so you can switch freely between them without
reinstalling.</sub>
## 📚 Documentation ## 📚 Documentation
- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks - **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines - **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next - **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
## 🤝 Contributing
Bug reports, ideas, and patches are all welcome on
**[Codeberg](https://codeberg.org/jlmakiola/calendula/issues)**.
The short version: **start with an issue.** Features get a yes-or-no before they
get code, and both features and bugs are assigned a milestone whose
`release/vX.Y.Z` branch your pull request then targets. Typo and docs fixes can
skip straight to a pull request. Translations don't go through pull requests at
all — [Weblate owns them](#-translations).
Read **[CONTRIBUTING.md](CONTRIBUTING.md)** before writing code: it covers the
workflow, the build (note the `floret-kit` submodule), and the architectural
rules a change is reviewed against.
## 🌍 Translations ## 🌍 Translations
Calendula ships in German and English, and you're warmly invited to add your Calendula ships in English and German, with community translations in Arabic,
language. Translations are managed on a self-hosted **Weblate**: Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying
degrees of completeness — partial is fine, untranslated strings simply fall back
to English. You're warmly invited to add or finish your language. Translations
are managed on a self-hosted **Weblate**:
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)** **→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21401 versionCode = 21800
versionName = "2.14.1" versionName = "2.18.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -113,10 +113,13 @@ android {
lint { lint {
// Community translations are expected to be partial — a missing string // Community translations are expected to be partial — a missing string
// falls back to the English base at runtime — so don't fail the build on // falls back to the English base at runtime — so don't fail the build on
// it. Stale/extra keys (ExtraTranslation) stay fatal; scripts/ // it. Likewise a translated <plurals> may not fill every CLDR quantity
// form its locale defines (e.g. Arabic needs "zero"); the missing form
// falls back to "other" at runtime, so MissingQuantity is informational
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// check_translations.py guards the same invariants with clearer, // check_translations.py guards the same invariants with clearer,
// translator-facing messages. // translator-facing messages.
informational += "MissingTranslation" informational += listOf("MissingTranslation", "MissingQuantity")
} }
testOptions { testOptions {

View File

@@ -38,3 +38,15 @@
# SessionWorker never ran, and widgets were stuck on their loading layout # SessionWorker never ran, and widgets were stuck on their loading layout
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor. # (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
-keep class * extends androidx.work.InputMerger { <init>(...); } -keep class * extends androidx.work.InputMerger { <init>(...); }
# Glance identifies a widget by its GlanceAppWidget subclass's *canonical name*:
# GlanceAppWidgetManager persists a providerName -> receivers map under that
# string, and `updateAll` looks the widget's app-widget ids up through it. Under
# R8 full mode (AGP 9 default) MonthWidget and AgendaWidget — same supertype,
# same overrides, no distinguishing members — were horizontally merged into one
# class, so both receivers registered under the *same* provider name and
# `AgendaWidget().updateAll()` resolved the month widget's id too, redrawing a
# placed month widget as the agenda one on the next data change (#89). Keeping
# the real names also survives app updates, which would otherwise renumber the
# obfuscated name and orphan the stored mapping.
-keep class * extends androidx.glance.appwidget.GlanceAppWidget

View File

@@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule import androidx.test.rule.GrantPermissionRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest {
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create( val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") }, produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
) )
return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO) return CalendarRepositoryImpl(
dataSource,
CalendarPrefs(store),
SettingsPrefs(store),
Dispatchers.IO,
)
} }
@Test @Test

View File

@@ -33,6 +33,14 @@
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" /> <uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!--
A reboot clears every pending alarm, including the one holding the next
reminder. Now that the app schedules that alarm itself (#75) rather than
leaning on the provider's, it has to hear about the reboot to re-arm it —
otherwise reminders simply stop after a restart.
-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage <!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't returns null and the calendar manager's per-account "manage" button can't
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
@@ -63,10 +71,9 @@
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
<action android:name="android.intent.action.MAIN" /> the two <activity-alias> below, so the app name can be switched at
<category android:name="android.intent.category.LAUNCHER" /> runtime (issue #44). MainActivity keeps every other filter. -->
</intent-filter>
<!-- Be selectable as the system calendar app. Android has no API for <!-- Be selectable as the system calendar app. Android has no API for
an app to make itself the default, so registering the filters an app to make itself the default, so registering the filters
@@ -97,32 +104,99 @@
<data android:mimeType="time/epoch" /> <data android:mimeType="time/epoch" />
</intent-filter> </intent-filter>
<!-- Open a .ics file (file manager / email attachment / browser). --> <!-- Open a .ics/.vcs file (file manager / email attachment / browser).
The three MIME types cover the common labels the same calendar
data arrives under: iCalendar 2.0 (text/calendar), the older
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
mail apps emit — Android cross-products the scheme and mimeType
tags, so each MIME is accepted on both schemes (matches Etar). -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" android:mimeType="text/calendar" /> <data android:scheme="content" />
<data android:scheme="file" android:mimeType="text/calendar" /> <data android:scheme="file" />
<data android:mimeType="text/calendar" />
<data android:mimeType="text/x-vcalendar" />
<data android:mimeType="application/ics" />
</intent-filter> </intent-filter>
<!-- Receive a .ics shared from another app. --> <!-- Same .ics/.vcs data arriving mislabelled as a generic download —
application/octet-stream — the way many mail clients, browsers and
file managers hand off attachments. Matched by file extension, so
this stays a separate filter: a pathPattern here must not narrow
the MIME-typed VIEW filter above (that one has no path and must
keep matching regardless of name). The import handler ignores the
MIME type, so a let-through octet-stream .ics imports normally.
Best-effort: pathPattern is reliable for file:// (and content://
whose path carries the filename); content:// URIs that expose no
name still fall back to the MIME-typed filter above. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:host="*" />
<data android:mimeType="application/octet-stream" />
<data android:pathPattern=".*\\.ics" />
<data android:pathPattern=".*\\.vcs" />
</intent-filter>
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/calendar" /> <data android:mimeType="text/calendar" />
<data android:mimeType="text/x-vcalendar" />
<data android:mimeType="application/ics" />
</intent-filter> </intent-filter>
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us <!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
to create a new event, the way the AOSP calendar accepts it: to create a new event, the way the AOSP calendar accepts it:
ACTION_INSERT on the events dir mime type, carrying the new ACTION_INSERT on the events *dir* mime type, carrying the new
event's fields as CalendarContract extras event's fields as CalendarContract extras
(MainActivity.insertFormOrNull, issue #30). --> (MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
to the same prefilled create form. (The far more common *item*-
typed INSERT — the form the Android docs' example and apps like
DB Navigator use — is the item filter below, issue #74.) -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.INSERT" /> <action android:name="android.intent.action.INSERT" />
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.dir/event" /> <data android:mimeType="vnd.android.cursor.dir/event" />
</intent-filter> </intent-filter>
<!-- Create or edit an event another app/assistant/widget points at,
addressed by the provider's *item* MIME type. Three actions share
this filter, told apart at runtime by the intent's data:
• ACTION_INSERT — create. This is the form the Android docs'
"insert an event" example and many apps use
(setType("vnd.android.cursor.item/event")), e.g. DB Navigator's
"Save to calendar". The dir-typed filter above alone missed it,
so Calendula never showed in the chooser — and, when it was the
only calendar app, the intent resolved to nothing (issue #74).
• ACTION_INSERT_OR_EDIT — the third "add to calendar" action AOSP
and Google Calendar register; a create, or an edit when it
carries an event id.
• ACTION_EDIT — edit the existing event at
content://com.android.calendar/events/<id> (an id-less EDIT is a
create). Opens the occurrence in the edit form, not the
read-only detail — that's the VIEW filter above.
Create fields ride as CalendarContract extras; an edit's
occurrence times ride as EXTRA_EVENT_BEGIN_TIME /
EXTRA_EVENT_END_TIME when supplied (MainActivity.insertFormOrNull
/ editEventKeyOrNull). -->
<intent-filter>
<action android:name="android.intent.action.INSERT" />
<action android:name="android.intent.action.INSERT_OR_EDIT" />
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter>
<!-- Open an existing event another app/widget points at (e.g. tapping <!-- Open an existing event another app/widget points at (e.g. tapping
an event in the Todo Agenda widget): ACTION_VIEW on an event in the Todo Agenda widget): ACTION_VIEW on
content://com.android.calendar/events/<id>, the way AOSP fires it. content://com.android.calendar/events/<id>, the way AOSP fires it.
@@ -139,11 +213,47 @@
<data android:mimeType="vnd.android.cursor.item/event" /> <data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter> </intent-filter>
<!-- Launcher long-press shortcuts (e.g. "New event"). --> </activity>
<!-- Launcher entry for MainActivity, split into two aliases so the app's
launcher name can be switched at runtime between "Calendula" and
"Calendar" (issue #44). Exactly one is enabled at a time; the app
flips them via PackageManager.setComponentEnabledSetting
(LauncherNameManager). The shortcuts meta-data lives here, not on
MainActivity, because static shortcuts are published by whichever
component owns the MAIN/LAUNCHER filter. -->
<activity-alias
android:name=".DefaultNameAlias"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
</activity> </activity-alias>
<activity-alias
android:name=".CalendarNameAlias"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name_calendar_alias"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<!-- Standalone surface for a captured crash report. MainActivity routes <!-- Standalone surface for a captured crash report. MainActivity routes
here on a startup crash-loop, so it stays clear of the app's Hilt here on a startup crash-loop, so it stays clear of the app's Hilt
@@ -168,17 +278,22 @@
</intent-filter> </intent-filter>
</service> </service>
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts <!-- Reminder delivery is the app's own (#75): it plans the alarms from
no notification itself — a calendar app must (v1.4, Etar model). Instances + Reminders instead of waiting for the provider's
Exported: the broadcast arrives from the provider's process. --> EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
retarget or never send. This receiver takes our scan alarm plus
every outside event that invalidates it — boot and package-replace
wipe pending alarms, and a clock or timezone change moves every
reminder relative to the one that is armed.
Exported: the system broadcasts arrive from outside the app. -->
<receiver <receiver
android:name=".data.reminders.EventReminderReceiver" android:name=".data.reminders.ReminderScheduleReceiver"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.EVENT_REMINDER" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
<data <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
android:host="com.android.calendar" <action android:name="android.intent.action.TIME_SET" />
android:scheme="content" /> <action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter> </intent-filter>
</receiver> </receiver>

View File

@@ -4,9 +4,12 @@ import android.app.Application
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
import de.jeanlucmakiola.calendula.data.backup.BackupWorker import de.jeanlucmakiola.calendula.data.backup.BackupWorker
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -39,6 +42,37 @@ class CalendulaApp : Application() {
) )
reconcileAutoBackup() reconcileAutoBackup()
reconcileSpecialDates() reconcileSpecialDates()
reconcileCalendarVisibility()
startReminderDelivery()
}
/**
* Bring reminder delivery up with the process (#75): a scan re-arms whatever
* the system dropped and posts what a missed alarm still owes, then the
* provider watch keeps edits re-planned. The daily worker is the backstop.
*/
private fun startReminderDelivery() {
val deps = EntryPointAccessors.fromApplication(
this, ReminderMaintenanceWorker.Deps::class.java,
)
val scanner = deps.reminderScanner()
scanner.startWatchingProvider()
scanner.scanInBackground()
ReminderMaintenanceScheduler.apply(this)
}
/**
* Flush any calendar switch-off not yet written to `Calendars.VISIBLE`,
* including the set inherited from the retired app-local model (#75). A
* no-op in the steady state; `RootScreen` re-runs it after a later grant.
*/
private fun reconcileCalendarVisibility() {
val deps = EntryPointAccessors.fromApplication(
this, CalendarVisibilityReconciler.Deps::class.java,
)
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
deps.calendarVisibilityReconciler().run()
}
} }
/** /**
@@ -61,10 +95,8 @@ class CalendulaApp : Application() {
/** /**
* Re-arm (or cancel) the daily special-dates reconcile from the saved * Re-arm (or cancel) the daily special-dates reconcile from the saved
* settings on every launch — like [reconcileAutoBackup], this cancels * settings, like [reconcileAutoBackup]. The on-open refresh is RootScreen's
* orphaned work after the feature is turned off and re-schedules after a * ON_RESUME trigger, so no immediate run is needed here.
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
* refresh, so no immediate run is needed here.
*/ */
private fun reconcileSpecialDates() { private fun reconcileSpecialDates() {
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java) val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)

View File

@@ -2,9 +2,11 @@ package de.jeanlucmakiola.calendula
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.CalendarContract import android.provider.CalendarContract
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@@ -12,6 +14,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -29,10 +32,14 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
import de.jeanlucmakiola.floret.components.DebugRibbon import de.jeanlucmakiola.floret.components.DebugRibbon
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.domain.FontRole
@@ -49,9 +56,17 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
/** A prefilled create form from an external launch, with the source it came from. */
private data class InsertRequest(val form: EventForm, val source: ImportSource)
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
// Which of light/dark the system bars are drawn for. The styles installed
// in onCreate read this field live, so androidx's config-change replay
// picks up the in-app override instead of the night resource qualifier.
private var systemBarsDark = false
// The occurrence a reminder notification was tapped for (eventId, begin, // The occurrence a reminder notification was tapped for (eventId, begin,
// end — the detail screen's key shape). singleTop + onNewIntent route a // end — the detail screen's key shape). singleTop + onNewIntent route a
// tap into the running activity; CalendarHost consumes and clears it. // tap into the running activity; CalendarHost consumes and clears it.
@@ -68,7 +83,12 @@ class MainActivity : AppCompatActivity() {
// A prefilled new-event form from an external ACTION_INSERT launch (another // A prefilled new-event form from an external ACTION_INSERT launch (another
// app/widget asking us to create an event, issue #30). Consumed once by // app/widget asking us to create an event, issue #30). Consumed once by
// CalendarHost, which opens it in the create form for review. // CalendarHost, which opens it in the create form for review.
private var requestedInsertForm by mutableStateOf<EventForm?>(null) private var requestedInsert by mutableStateOf<InsertRequest?>(null)
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
// opens the occurrence in the edit form. Same occurrence-key shape as the
// detail channel; consumed once by CalendarHost.
private var requestedEditKey by mutableStateOf<LongArray?>(null)
// A captured crash report awaiting the user's decision, surfaced as a dialog // A captured crash report awaiting the user's decision, surfaced as a dialog
// over the calendar on the next launch (the single-crash path). A startup // over the calendar on the next launch (the single-crash path). A startup
@@ -90,11 +110,14 @@ class MainActivity : AppCompatActivity() {
return return
} }
enableEdgeToEdge() systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
applyEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull() requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedNav = intent.navRequestOrNull() requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull() requestedImportUri = intent.importUriOrNull()
requestedInsertForm = intent.insertFormOrNull() requestedInsert = intent.insertRequestOrNull()
requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent { setContent {
// One activity-scoped SettingsViewModel drives both the theme here // One activity-scoped SettingsViewModel drives both the theme here
@@ -106,6 +129,14 @@ class MainActivity : AppCompatActivity() {
ThemeMode.LIGHT -> false ThemeMode.LIGHT -> false
ThemeMode.DARK -> true ThemeMode.DARK -> true
} }
// onCreate can only see the night resource qualifier, not the in-app
// override — re-apply from the resolved theme so the bar icons follow
// the app's light/dark choice.
DisposableEffect(darkTheme) {
systemBarsDark = darkTheme
applyEdgeToEdge()
onDispose {}
}
// The app-wide clock convention: the time-format preference resolved // The app-wide clock convention: the time-format preference resolved
// against the device's 24-hour system setting, provided once here so // against the device's 24-hour system setting, provided once here so
// every time label reads it via LocalUse24HourFormat. // every time label reads it via LocalUse24HourFormat.
@@ -118,6 +149,13 @@ class MainActivity : AppCompatActivity() {
// re-import stamp AppFontSettings carries, so replacing the file // re-import stamp AppFontSettings carries, so replacing the file
// behind an active "custom" token still refreshes — changes; // behind an active "custom" token still refreshes — changes;
// "system for both" returns the default scale untouched. // "system for both" returns the default scale untouched.
// The timeline scale plus the pinch in flight over it (#56). Held
// here, above the calendar views, so a zoom survives paging between
// weeks and switching between the week and day view.
val timelineZoom = rememberTimelineZoom(
stored = settings.timelineScale,
onPersist = settingsViewModel::setTimelineScale,
)
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle() val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
val typography = remember(fonts, context) { val typography = remember(fonts, context) {
calendulaTypography( calendulaTypography(
@@ -134,6 +172,8 @@ class MainActivity : AppCompatActivity() {
CompositionLocalProvider( CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour, LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines, LocalShowHourLines provides settings.showHourLines,
LocalTimelineZoom provides timelineZoom,
LocalSoftenColors provides settings.softenColors,
) { ) {
RootScreen( RootScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -143,8 +183,11 @@ class MainActivity : AppCompatActivity() {
onWidgetNavConsumed = { requestedNav = null }, onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri, requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null }, onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsertForm, requestedInsertForm = requestedInsert?.form,
onInsertConsumed = { requestedInsertForm = null }, requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
onInsertConsumed = { requestedInsert = null },
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
) )
} }
// A persistent corner marker so a debug build is never // A persistent corner marker so a debug build is never
@@ -171,6 +214,19 @@ class MainActivity : AppCompatActivity() {
} }
} }
/**
* Applies the transparent edge-to-edge bars for the current [systemBarsDark].
* Both scrims are transparent because API 29+ enforces its own contrast and
* ignores them anyway.
*/
private fun applyEdgeToEdge() {
val transparent = android.graphics.Color.TRANSPARENT
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
navigationBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
)
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// Reaching a running UI means startup succeeded; reset the loop trail. // Reaching a running UI means startup succeeded; reset the loop trail.
@@ -182,7 +238,8 @@ class MainActivity : AppCompatActivity() {
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it } (intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it } intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it } intent.importUriOrNull()?.let { requestedImportUri = it }
intent.insertFormOrNull()?.let { requestedInsertForm = it } intent.insertRequestOrNull()?.let { requestedInsert = it }
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
} }
/** /**
@@ -203,16 +260,27 @@ class MainActivity : AppCompatActivity() {
} }
/** /**
* A prefilled new-event form from an external `ACTION_INSERT` launch — another * A prefilled new-event form from an external launch asking us to create an
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30). * event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
* The new event's fields ride as CalendarContract extras; anything omitted * (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
* falls back to the in-app "new event" defaults in [buildInsertEventForm]. * event", i.e. create). The new event's fields ride as CalendarContract
* extras; anything omitted falls back to the in-app "new event" defaults in
* [buildInsertEventForm]. An intent that names no end time leaves the length
* to the default-duration setting ([ImportSource.InsertOpenEnded], #54).
*/ */
private fun Intent.insertFormOrNull(): EventForm? { private fun Intent.insertRequestOrNull(): InsertRequest? {
if (action != Intent.ACTION_INSERT) return null // ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
return buildInsertEventForm( // edit form instead ([editEventKeyOrNull]); an id-less one is a create,
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME), // as is any plain ACTION_INSERT.
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME), val isCreate = action == Intent.ACTION_INSERT ||
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
editEventKeyOrNull() == null)
if (!isCreate) return null
val beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME)
val endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME)
val form = buildInsertEventForm(
beginMillis = beginMillis,
endMillis = endMillis,
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false), isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
title = getStringExtra(CalendarContract.Events.TITLE), title = getStringExtra(CalendarContract.Events.TITLE),
description = getStringExtra(CalendarContract.Events.DESCRIPTION), description = getStringExtra(CalendarContract.Events.DESCRIPTION),
@@ -221,6 +289,10 @@ class MainActivity : AppCompatActivity() {
zone = TimeZone.currentSystemDefault(), zone = TimeZone.currentSystemDefault(),
now = Clock.System.now(), now = Clock.System.now(),
) )
// An end the intent didn't name — or one [buildInsertEventForm] drops for
// landing before the start — leaves the length to the setting.
val namesEnd = beginMillis != null && endMillis != null && endMillis >= beginMillis
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
} }
/** A Long extra's value, or null when the extra is absent. */ /** A Long extra's value, or null when the extra is absent. */
@@ -318,6 +390,32 @@ class MainActivity : AppCompatActivity() {
) )
} }
/**
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
* an assistant, task app, or widget that wants to open the event for editing
* rather than viewing). Opens it in the edit form. Reuses the same
* occurrence-key channel as reminder/view taps; the caller passes the
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
* [EventEditViewModel.openForEdit] falls back to the event row's own
* DTSTART/DTEND. An id-less `ACTION_EDIT` — or `ACTION_INSERT_OR_EDIT`, which
* some apps fire — is a create instead ([insertFormOrNull]).
*/
private fun Intent.editEventKeyOrNull(): LongArray? {
if (action != Intent.ACTION_EDIT && action != Intent.ACTION_INSERT_OR_EDIT) return null
val uri = data ?: return null
if (uri.host != CALENDAR_PROVIDER_HOST) return null
val segments = uri.pathSegments
if (segments.firstOrNull() != "events") return null
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
return longArrayOf(
eventId,
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
)
}
companion object { companion object {
// The calendar provider's authority/host. A date tap arrives as // The calendar provider's authority/host. A date tap arrives as
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>. // ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.

View File

@@ -0,0 +1,109 @@
package de.jeanlucmakiola.calendula.data.appname
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/** The launcher label the app shows for itself (issue #44). */
enum class LauncherName { CALENDULA, CALENDAR }
/** The two launcher aliases declared in the manifest. */
enum class LauncherAlias { DEFAULT, CALENDAR }
/** One component-enable change in a [aliasWritePlan]. */
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
/**
* Interpret the `CalendarNameAlias` component-enabled state as a [LauncherName].
* Only [PackageManager.COMPONENT_ENABLED_STATE_ENABLED] means the "Calendar"
* name is active; `DEFAULT` (never toggled) and `DISABLED` both resolve to the
* manifest default, "Calendula".
*/
fun launcherNameFor(calendarAliasState: Int): LauncherName =
if (calendarAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
LauncherName.CALENDAR
} else {
LauncherName.CALENDULA
}
/**
* The ordered enable/disable steps to switch the launcher name to [target].
* Always **enables the target alias first, then disables the other** — the two
* `setComponentEnabledSetting` calls are not atomic, and this ordering means the
* only transient the launcher can observe is a harmless two-entry state, never a
* zero-entry one (which would briefly drop the app from the launcher).
*/
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
LauncherName.CALENDAR -> listOf(
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
)
LauncherName.CALENDULA -> listOf(
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
)
}
/**
* Switches the app's launcher label between "Calendula" and "Calendar" by
* enabling/disabling the two `<activity-alias>` components (issue #44). The
* component-enabled state is the single source of truth — there is no persisted
* preference — so [current] reads it straight from [PackageManager] and the two
* always agree.
*
* The decision logic lives in the pure [launcherNameFor] / [aliasWritePlan]
* functions above (JVM-tested); this class is only the thin framework seam.
*/
@Singleton
class LauncherNameManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val packageManager: PackageManager get() = context.packageManager
// The class portion is namespace-qualified (suffix-free), the package portion
// is the applicationId (which carries the .debug / .releasetest suffix). The
// manifest's ".CalendarNameAlias" resolves its class against the namespace, so
// the real component is <namespace>.CalendarNameAlias registered under the
// suffixed applicationId. Do NOT use ComponentName(context, ".CalendarNameAlias")
// — its leading-dot form prepends the applicationId to the class too, producing
// "<appId>.CalendarNameAlias" and failing on every debug / releaseTest build.
private fun component(alias: LauncherAlias): ComponentName {
val simpleName = when (alias) {
LauncherAlias.DEFAULT -> "DefaultNameAlias"
LauncherAlias.CALENDAR -> "CalendarNameAlias"
}
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
}
/** The launcher name currently in effect. */
fun current(): LauncherName =
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.CALENDAR)))
/** Switch the launcher name to [name] (no-op cost if already active). */
fun set(name: LauncherName) {
for (change in aliasWritePlan(name)) {
val state = if (change.enabled) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
packageManager.setComponentEnabledSetting(
component(change.alias),
state,
PackageManager.DONT_KILL_APP,
)
}
}
private companion object {
/**
* The app's namespace (R-class package) — NOT `applicationId`, which
* carries the build-type suffix. Kept in sync with `namespace` in
* `app/build.gradle.kts`.
*/
const val NAMESPACE = "de.jeanlucmakiola.calendula"
}
}

View File

@@ -1,11 +1,10 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import java.time.Instant import de.jeanlucmakiola.calendula.domain.reminders.allDayLeadDays
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
/** /**
* Translates an all-day reminder between the **semantic** lead time the UI * Translates an all-day reminder between the **semantic** lead time the UI
@@ -73,19 +72,13 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
/** /**
* Recover the semantic whole-day lead time from a raw all-day reminder * Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it * [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
* returns the right day count regardless of which [timeOfDayMinutes] wrote the * detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight) * the reminder actually fires on.
* and rows written under a different timezone. A negative [rawMinutes] (fire
* after DTSTART) folds to day 0.
*/ */
internal fun fromProviderAllDayMinutes( internal fun fromProviderAllDayMinutes(
rawMinutes: Int, rawMinutes: Int,
startDate: LocalDate, startDate: LocalDate,
zone: ZoneId, zone: ZoneId,
): Int { timeOfDayMinutes: Int,
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() ): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
.atZone(zone).toLocalDate()
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
}

View File

@@ -18,6 +18,8 @@ import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
@@ -52,7 +54,12 @@ import javax.inject.Singleton
interface CalendarDataSource { interface CalendarDataSource {
fun calendars(): List<CalendarSource> fun calendars(): List<CalendarSource>
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
fun eventDetail(eventId: Long): EventDetail? /**
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
* decode their stored offsets back to whole-day lead times (see
* [fromProviderAllDayMinutes]).
*/
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
/** /**
* Master/one-off events whose title, description or location contains * Master/one-off events whose title, description or location contains
@@ -108,6 +115,28 @@ interface CalendarDataSource {
/** Permanently delete a local calendar the app owns, with all its events. */ /** Permanently delete a local calendar the app owns, with all its events. */
fun deleteCalendar(id: Long) fun deleteCalendar(id: Long)
/**
* Show or hide the calendar device-wide by writing `Calendars.VISIBLE` — the
* app's one visibility model (#75), which also gates its reminders. One of
* the three columns the platform documents as app-writable, and device-local.
*/
fun setCalendarVisible(id: Long, visible: Boolean)
/**
* Whether one calendar is switched on at system level, without reading every
* row — for the reminder gate, which sees only a calendar id. Null when the
* answer can't be had: no row, or no read permission.
*/
fun isCalendarVisible(id: Long): Boolean?
/**
* Whether the app holds `WRITE_CALENDAR`, i.e. may write
* [setCalendarVisible] at all. Read-only users (READ granted, WRITE denied)
* keep their calendar switches app-side instead — see
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds].
*/
fun canWriteCalendars(): Boolean
/** /**
* Create a local calendar tagged as the special-dates mirror for [type] * Create a local calendar tagged as the special-dates mirror for [type]
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal * (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
@@ -179,6 +208,25 @@ interface CalendarDataSource {
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
) )
/**
* Move an event (for recurring events: the whole series, with its modified
* and cancelled occurrences) to [targetCalendarId], returning the new
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
* place, so this is copy+delete: the row is re-inserted on the target (its
* `UID_2445` preserved), its exceptions, reminders and editable guests
* replayed, the [original]→[updated] field edits applied, then the source
* deleted. The source is removed only once the copy fully succeeds; a failure
* before that rolls the new event back, leaving the original untouched.
* [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long
/** /**
* Change a single occurrence of a recurring event by inserting a * Change a single occurrence of a recurring event by inserting a
* modified-occurrence exception at [beginMillis] (the occurrence's * modified-occurrence exception at [beginMillis] (the occurrence's
@@ -352,6 +400,37 @@ class AndroidCalendarDataSource @Inject constructor(
if (deleted == 0) throw WriteFailedException("delete calendar id=$id") if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
} }
/**
* Addressed by appended id on the plain (non-sync-adapter) Calendars URI, one
* calendar per call. Both parts are load-bearing:
* `CalendarProvider2.updateInTransaction` skips the dirty marking and the
* `checkNextAlarm()` reschedule unless the selection is `_id=…`, and the
* plain URI is what makes the write apply to synced calendars too.
*/
override fun setCalendarVisible(id: Long, visible: Boolean) {
val values = ContentValues().apply {
put(CalendarContract.Calendars.VISIBLE, if (visible) 1 else 0)
}
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
values, null, null,
)
if (rows == 0) throw WriteFailedException("set calendar visibility id=$id")
}
override fun isCalendarVisible(id: Long): Boolean? {
if (!hasCalendarPermission()) return null
return resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
arrayOf(CalendarContract.Calendars.VISIBLE),
null, null, null,
)?.use { if (it.moveToFirst()) it.getInt(0) != 0 else null }
}
override fun canWriteCalendars(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long { override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR } val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply { val values = ContentValues().apply {
@@ -589,7 +668,7 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
override fun eventDetail(eventId: Long): EventDetail? { override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? {
val attendees = queryAttendees(eventId) val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId) val reminders = queryReminders(eventId)
return resolver.query( return resolver.query(
@@ -598,7 +677,9 @@ class AndroidCalendarDataSource @Inject constructor(
null, null, null, null, null, null,
)?.use { c -> )?.use { c ->
if (!c.moveToFirst()) null if (!c.moveToFirst()) null
else CursorColumnReader(c).toEventDetailCore(attendees, reminders) else CursorColumnReader(c).toEventDetailCore(
attendees, reminders, allDayReminderTimeMinutes,
)
} }
} }
@@ -885,6 +966,190 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
override fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
// CALENDAR_ID can't be updated in place, so re-create the event on the
// target calendar and delete the source. Everything that identifies or
// hangs off the event is copied first; the source row is removed only
// once the copy (including its exceptions) is complete — a failure
// anywhere before that rolls the new event back, so the move is
// all-or-nothing and never leaves a half-copied duplicate.
val master = queryMoveMaster(eventId)
?: throw WriteFailedException("read event to move id=$eventId")
// Keep the source UID so an .ics backup dedups and a sync adapter can
// recognise the moved event; mint one only if the source carried none.
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula"
val newEventId = resolver.insert(
CalendarContract.Events.CONTENT_URI,
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
)?.let(ContentUris::parseId)
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
try {
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
insertAttendees(newEventId, editableAttendees(eventId))
copyExceptions(fromEventId = eventId, toEventId = newEventId)
// Apply the user's field edits exactly as a same-calendar "all
// events" save would: the moved master carries the source's values,
// so the dirty diff against [original] writes only what changed
// (including a time or rrule edit made in the same save).
updateEvent(newEventId, original, updated, allDayReminderTimeMinutes)
} catch (t: Throwable) {
// Undo the partial copy so a failed move leaves nothing behind; the
// source is still intact (it's deleted only past this point).
runCatching { deleteEvent(newEventId) }
throw t
}
deleteEvent(eventId)
return newEventId
}
/** The master row of the event to move, as a verbatim-insert snapshot. */
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
MoveMasterProjection.COLUMNS,
null, null, null,
)?.use { c ->
if (!c.moveToFirst()) return@use null
val r = CursorColumnReader(c)
MoveMaster(
uid = r.getString(MoveMasterProjection.IDX_UID),
event = MasterEventSnapshot(
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND)
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
duration = r.getString(MoveMasterProjection.IDX_DURATION),
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
exdate = r.getString(MoveMasterProjection.IDX_EXDATE),
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
status = r.getInt(MoveMasterProjection.IDX_STATUS)
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
location = r.getString(MoveMasterProjection.IDX_LOCATION),
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
),
)
}
private data class MoveMaster(val uid: String?, val event: MasterEventSnapshot)
/** Copy every stored reminder row (raw offsets and method) onto [toEventId]. */
private fun copyReminderRows(fromEventId: Long, toEventId: Long) = resolver.query(
CalendarContract.Reminders.CONTENT_URI,
arrayOf(CalendarContract.Reminders.MINUTES, CalendarContract.Reminders.METHOD),
CalendarContract.Reminders.EVENT_ID + " = ?",
arrayOf(fromEventId.toString()),
null,
)?.use { c ->
while (c.moveToNext()) {
val values = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, toEventId)
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
put(CalendarContract.Reminders.METHOD, c.getInt(1))
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
}
}
} ?: Unit
/**
* The event's editable guests as [EventAttendee]s — the rows the move
* re-creates. Mirrors the edit form's filter: the organizer and resource
* rows (backend-owned, not user-editable) and any without an email are
* dropped; the response status resets to "invited" on re-insert.
*/
private fun editableAttendees(eventId: Long): List<EventAttendee> = queryAttendees(eventId)
.filter {
it.relationship != AttendeeRelationship.Organizer && it.type != AttendeeType.Resource
}
.mapNotNull { a ->
a.email?.takeIf { it.isNotBlank() }?.let { email ->
EventAttendee(email = email, name = a.name, optional = a.type == AttendeeType.Optional)
}
}
/**
* Replay every exception of the series [fromEventId] against the moved series
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence
* time still resolves: a cancelled occurrence is re-hidden with a cancelled
* exception, a modified one is re-inserted with its fields (then its reminders
* reconciled and editable guests copied). No-op for a non-recurring event.
*/
private fun copyExceptions(fromEventId: Long, toEventId: Long) {
queryExceptionRows(fromEventId).forEach { ex ->
if (ex.isCancelled) {
val values = ContentValues().apply {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis)
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
}
resolver.insert(
ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
),
values,
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId")
} else {
val uri = resolver.insert(
ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
),
buildCopiedExceptionValues(ex).toContentValues(),
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
val newExceptionId = ContentUris.parseId(uri)
// The provider may clone the parent's reminders onto the new
// exception; reconcile to the source exception's exact set so
// they neither double nor drop. Same DTSTART → same all-day
// encoding, so the stored offsets match directly.
reconcileReminders(
newExceptionId,
queryReminders(ex.exceptionEventId).map { it.minutes },
)
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
}
}
}
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
private fun queryExceptionRows(seriesEventId: Long): List<ExceptionRowSnapshot> = resolver.query(
CalendarContract.Events.CONTENT_URI,
ExceptionProjection.COLUMNS,
"${CalendarContract.Events.ORIGINAL_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
arrayOf(seriesEventId.toString()),
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
)?.use { c ->
c.mapAll {
val r = CursorColumnReader(c)
val status = r.getInt(ExceptionProjection.IDX_STATUS)
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
ExceptionRowSnapshot(
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
status = status,
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(),
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0,
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
duration = r.getString(ExceptionProjection.IDX_DURATION),
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
location = r.getString(ExceptionProjection.IDX_LOCATION),
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
)
}
} ?: emptyList()
override fun updateOccurrence( override fun updateOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,

View File

@@ -31,5 +31,8 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
isManaged = isLocal && isManaged = isLocal &&
getString(CalendarProjection.IDX_MANAGED_MARKER) getString(CalendarProjection.IDX_MANAGED_MARKER)
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true, ?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
// NULL is treated as syncing — the harmless default.
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
) )
} }

View File

@@ -38,6 +38,16 @@ interface CalendarRepository {
/** Permanently delete a local calendar the app owns, with all its events. */ /** Permanently delete a local calendar the app owns, with all its events. */
suspend fun deleteCalendar(id: Long) suspend fun deleteCalendar(id: Long)
/**
* Show or hide [ids] device-wide (`Calendars.VISIBLE`), which also gates
* their reminders — see [CalendarDataSource.setCalendarVisible]. Written one
* at a time, in order; a failure part-way leaves the earlier writes standing.
*
* Without `WRITE_CALENDAR` the choice is kept app-side instead (see
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]).
*/
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
/** /**
* Every event of the writable local calendars, ready to serialise into a * Every event of the writable local calendars, ready to serialise into a
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]). * whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
@@ -61,6 +71,20 @@ interface CalendarRepository {
*/ */
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
/**
* Move an event (recurring: the whole series, with its exceptions) to
* [targetCalendarId] and apply the [original]→[updated] field edits; returns
* the new event's `Events._ID`. Copy+delete under the hood
* (see [CalendarDataSource.moveEvent]) — `CALENDAR_ID` can't be updated in
* place.
*/
suspend fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
): Long
/** /**
* Change a single occurrence of a recurring event (exception row with the * Change a single occurrence of a recurring event (exception row with the
* form's values); returns the exception's `Events._ID`. * form's values); returns the exception's `Events._ID`.

View File

@@ -16,11 +16,17 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -47,52 +53,120 @@ class CalendarRepositoryImpl @Inject constructor(
extraBufferCapacity = 1, extraBufferCapacity = 1,
) )
/**
* Bumped on every provider notification, so one tick's calendar read can be
* shared by everything that needs it (see [calendarsSnapshot]).
*/
private val generation = AtomicLong(0L)
init { init {
dataSource.registerChangeListener { ticks.tryEmit(Unit) } dataSource.registerChangeListener {
generation.incrementAndGet()
ticks.tryEmit(Unit)
}
} }
override fun calendars(): Flow<List<CalendarSource>> = /**
ticks * Re-query signal for everything filtered by visibility: the provider's own
.onStart { emit(Unit) } * notifications, plus every change to the pending switch-off set.
.reQuery { dataSource.calendars() } */
.flowOn(io) private fun visibilityTicks(): Flow<Unit> = merge(
ticks.onStart { emit(Unit) },
// Instances are filtered by the app-side hidden disabled calendar sets // drop(1): the current value is already covered by the tick above.
// (M3): an event is dropped whenever the user has hidden *or* disabled its prefs.pendingDisabledCalendarIds.drop(1).map {},
// calendar. Re-runs when the provider ticks *or* either set changes —
// toggling a calendar in the filter sheet or the calendar manager updates
// every view immediately. [calendars] stays unfiltered so those screens can
// list and re-enable hidden/disabled calendars.
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
combine(
ticks
.onStart { emit(Unit) }
.reQuery {
dataSource.instances(
beginMillis = range.start.toEpochMillis(),
endMillis = range.endInclusive.toEpochMillis(),
) )
},
prefs.hiddenCalendarIds, // A switch-off not yet written to the provider is folded into the flag
prefs.disabledCalendarIds, // itself, so every consumer reads one visibility (#75). The reconciler goes
) { instances, hidden, disabled -> // to the data source directly — it needs the provider's own answer.
val excluded = hidden + disabled override fun calendars(): Flow<List<CalendarSource>> =
if (excluded.isEmpty()) instances visibilityTicks().reQuery {
else instances.filterNot { it.calendarId in excluded } val calendars = calendarsSnapshot()
val pendingDisabled = prefs.pendingDisabledCalendarIds.first()
if (pendingDisabled.isEmpty()) calendars
else calendars.map {
if (it.id in pendingDisabled) it.copy(isVisibleInSystem = false) else it
}
} }
// hidden and disabled both derive from one DataStore, so toggling
// either makes both re-emit and combine briefly surfaces the same
// list twice — collapse the duplicate so views don't re-render for it.
.distinctUntilChanged() .distinctUntilChanged()
.flowOn(io) .flowOn(io)
// Instances are filtered by the system's VISIBLE flag the switch-offs
// still waiting to be written to it the app-side hidden set from the
// filter sheet. [calendars] stays unfiltered so those screens can list and
// re-enable invisible calendars.
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
combine(
visibilityTicks().reQuery {
// All three reads in one pass, so a list of instances is never
// filtered against a visibility snapshot from another tick.
QueriedInstances(
instances = dataSource.instances(
beginMillis = range.start.toEpochMillis(),
endMillis = range.endInclusive.toEpochMillis(),
),
switchedOffCalendarIds = invisibleCalendarIds() +
prefs.pendingDisabledCalendarIds.first(),
)
},
prefs.hiddenCalendarIds,
) { queried, hidden ->
val excluded = hidden + queried.switchedOffCalendarIds
if (excluded.isEmpty()) queried.instances
else queried.instances.filterNot { it.calendarId in excluded }
}
// Any DataStore edit re-emits the hidden set even when unchanged;
// collapse those so views don't re-render for them.
.distinctUntilChanged()
.flowOn(io)
/** One instances query plus the visibility it must be filtered against. */
private data class QueriedInstances(
val instances: List<EventInstance>,
val switchedOffCalendarIds: Set<Long>,
)
/** Calendars switched off at system level — hidden, and never reminded about. */
private suspend fun invisibleCalendarIds(): Set<Long> = calendarsSnapshot()
.filterNot { it.isVisibleInSystem }
.mapTo(mutableSetOf()) { it.id }
private val calendarsLock = Mutex()
private var cachedGeneration = -1L
private var cachedPending: Set<Long>? = null
private var cachedCalendars: List<CalendarSource> = emptyList()
/**
* The calendar list for the current tick, queried once and shared, so every
* open view doesn't pay for its own `Calendars` query and all of them see
* one snapshot.
*
* An empty result is never cached — that is what a read without the calendar
* permission returns, and the grant itself doesn't notify the provider. The
* pending switch-off set keys the cache alongside the tick, because an id
* leaves it before the invalidating observer is dispatched.
*/
private suspend fun calendarsSnapshot(): List<CalendarSource> = calendarsLock.withLock {
val current = generation.get()
val pending = prefs.pendingDisabledCalendarIds.first()
if (current != cachedGeneration || pending != cachedPending || cachedCalendars.isEmpty()) {
cachedCalendars = dataSource.calendars()
cachedGeneration = current
cachedPending = pending
}
cachedCalendars
}
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) { override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
?: throw NoSuchEventException(eventId)
} }
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) { override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
if (query.isBlank()) return@withContext emptyList() if (query.isBlank()) return@withContext emptyList()
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first() val excluded = prefs.hiddenCalendarIds.first() +
prefs.pendingDisabledCalendarIds.first() +
invisibleCalendarIds()
dataSource.searchEvents(query) dataSource.searchEvents(query)
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } } .let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
} }
@@ -118,6 +192,22 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun deleteCalendar(id: Long) = override suspend fun deleteCalendar(id: Long) =
withContext(io) { dataSource.deleteCalendar(id) } withContext(io) { dataSource.deleteCalendar(id) }
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
withContext(io) {
if (dataSource.canWriteCalendars()) {
ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the
// write lands (and switching one back on retires its entry).
prefs.removePendingDisabledCalendarIds(ids)
} else if (visible) {
prefs.removePendingDisabledCalendarIds(ids)
} else {
// Read-only permission: the switch still works, app-side, and
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
prefs.addPendingDisabledCalendarIds(ids)
}
}
override suspend fun exportEvents(calendarIds: Set<Long>?) = override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds) } withContext(io) { dataSource.exportableEvents(calendarIds) }
@@ -157,6 +247,17 @@ class CalendarRepositoryImpl @Inject constructor(
dataSource.deleteEvent(eventId) dataSource.deleteEvent(eventId)
} }
override suspend fun moveEvent(
eventId: Long,
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
): Long = withContext(io) {
dataSource.moveEvent(
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
)
}
override suspend fun updateOccurrence( override suspend fun updateOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,

View File

@@ -0,0 +1,113 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
import javax.inject.Singleton
/**
* Keeps [CalendarPrefs.pendingDisabledCalendarIds] and the system's
* `Calendars.VISIBLE` in step (#75): the fold-in of the retired app-local
* visibility model, and the standing drain for switch-offs made without
* `WRITE_CALENDAR`.
*
* Runs on every launch and on every calendar-permission grant; a no-op once the
* pending set is empty and the notice is settled. Only ever hides (see
* [calendarVisibilityPlan]); on an upgraded install the first run that sees a
* system-hidden calendar arms the one-time explanatory notice.
*/
@Singleton
class CalendarVisibilityReconciler @Inject constructor(
@ApplicationContext private val context: Context,
private val dataSource: CalendarDataSource,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) {
suspend fun run() = withContext(io) {
// The DataStore reads sit inside the guard too: this runs in a bare
// application-scope coroutine, so an IOException from a damaged
// preferences file would take the process down on every launch.
try {
// Settled ahead of the permission gate, so an update installed
// before the first grant can't later look like an upgrade.
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
val pending = prefs.pendingDisabledCalendarIds.first()
val noticeSettled = prefs.visibilityNoticePending.first() != null
if (pending.isEmpty() && noticeSettled) return@withContext
val calendars = dataSource.calendars()
// Empty means "couldn't read" (null cursor), not "no calendars".
// Both decisions below are one-way, so leave them to the next run.
if (calendars.isEmpty()) return@withContext
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
return@withContext
}
val plan = calendarVisibilityPlan(calendars, pending)
prefs.removePendingDisabledCalendarIds(plan.settled)
// One calendar per write: the provider skips its reminder-alarm
// reschedule for anything but a single-id update (see
// [CalendarDataSource.setCalendarVisible]).
for (id in plan.hide) {
dataSource.setCalendarVisible(id, false)
prefs.removePendingDisabledCalendarIds(setOf(id))
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Calendar visibility reconcile failed; will retry", e)
}
}
/**
* Settle the one-time notice: [pending] arms it, false retires it unshown.
* Answered once and stored either way, so it can't resurface later.
*/
private suspend fun settleNoticeOnce(pending: Boolean) {
if (prefs.visibilityNoticePending.first() != null) return
prefs.setVisibilityNoticePending(pending)
}
/**
* Whether this install has ever run an earlier version. The notice explains
* a behaviour change, so a first install has nothing to announce.
*/
private fun isUpgradeInstall(): Boolean = try {
@Suppress("DEPRECATION")
val info = context.packageManager.getPackageInfo(context.packageName, 0)
info.lastUpdateTime > info.firstInstallTime
} catch (e: PackageManager.NameNotFoundException) {
Log.w(TAG, "Own package info unavailable; treating as a fresh install", e)
false
}
private fun hasPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Lets non-injectable entry points (the Application) reach the reconciler. */
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun calendarVisibilityReconciler(): CalendarVisibilityReconciler
}
private companion object {
const val TAG = "CalendarVisibility"
}
}

View File

@@ -24,6 +24,7 @@ private const val TAG = "EventDetailMapper"
internal fun ColumnReader.toEventDetailCore( internal fun ColumnReader.toEventDetailCore(
attendees: List<Attendee>, attendees: List<Attendee>,
reminders: List<Reminder>, reminders: List<Reminder>,
allDayReminderTimeMinutes: Int,
): EventDetail? { ): EventDetail? {
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common // DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately // for yearly birthdays/anniversaries synced over CalDAV) is legitimately
@@ -89,7 +90,13 @@ internal fun ColumnReader.toEventDetailCore(
val displayReminders = if (isAllDay) { val displayReminders = if (isAllDay) {
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate() val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
val zone = ZoneId.systemDefault() val zone = ZoneId.systemDefault()
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) } reminders.map {
it.copy(
minutes = fromProviderAllDayMinutes(
it.minutes, startDate, zone, allDayReminderTimeMinutes,
),
)
}
} else { } else {
reminders reminders
} }

View File

@@ -6,9 +6,11 @@ import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toJavaLocalDateTime
import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.LocalDateTime as JavaLocalDateTime
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */ /** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
internal data class EventWriteTimes( internal data class EventWriteTimes(
@@ -20,7 +22,11 @@ internal data class EventWriteTimes(
/** /**
* All-day events live at UTC midnights with an exclusive DTEND (the * All-day events live at UTC midnights with an exclusive DTEND (the
* CalendarContract convention — a one-day event ends at the next midnight); * CalendarContract convention — a one-day event ends at the next midnight);
* timed events resolve their wall-clock values in [zone]. * timed events resolve their wall-clock values in the form's own
* [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin
* one. Passing the device zone is therefore still correct for an unpinned form —
* but it no longer overrides a pinned event's zone, which is what used to
* silently re-anchor a foreign-zone event to the device on any time edit.
*/ */
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) { internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
EventWriteTimes( EventWriteTimes(
@@ -31,22 +37,56 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
timezone = "UTC", timezone = "UTC",
) )
} else { } else {
val writeZone = writeZone(zone)
EventWriteTimes( EventWriteTimes(
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
timezone = zone.id, timezone = writeZone.id,
) )
} }
/**
* The zone this form's DTSTART is expressed in: UTC for an all-day event (the
* provider's date anchor), otherwise the form's own pinned zone, falling back to
* [deviceZone] when it doesn't pin one or pins something the tz database can't
* parse.
*/
private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
ZoneOffset.UTC
} else {
timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: deviceZone
}
/**
* The form's start as a bare wall-clock value — what the user sees on the form,
* stripped of any zone. All-day events use their date's midnight rather than
* [EventForm.start]'s placeholder time-of-day, which exists only so switching the
* event back to timed has something to show.
*/
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
start.date.toJavaLocalDate().atStartOfDay()
} else {
start.toJavaLocalDateTime()
}
/** /**
* RFC 2445 duration for a recurring event's row (the provider requires * RFC 2445 duration for a recurring event's row (the provider requires
* DURATION instead of DTEND when an RRULE is set): whole days for all-day * DURATION instead of DTEND when an RRULE is set): whole days for all-day
* events, seconds otherwise. * events, seconds otherwise.
*/ */
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) { internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D" rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay)
/**
* RFC 2445 duration for a [spanMillis]-long event: whole days for all-day
* events (the provider's convention), seconds otherwise. Shared by the write
* paths that need a DURATION but start from a raw millisecond span (the series
* copy and exception replay of a calendar move) rather than [EventWriteTimes].
*/
internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) {
"P${spanMillis / MILLIS_PER_DAY}D"
} else { } else {
"P${(dtEndMillis - dtStartMillis) / 1_000L}S" "P${spanMillis / 1_000L}S"
} }
/** /**
@@ -95,10 +135,12 @@ internal fun buildEventInsertValues(
* Time fields travel together (the provider validates them as a unit): * Time fields travel together (the provider validates them as a unit):
* - unchanged times, all-day flag and rrule → no time columns at all; * - unchanged times, all-day flag and rrule → no time columns at all;
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared; * - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
* - recurring result → the *series* DTSTART moves by the same delta the user * - recurring result → the *series* DTSTART moves by the same **wall-clock**
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's * shift the user applied to the displayed occurrence and is re-resolved in the
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps * event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
* past occurrences intact when someone edits a later occurrence's time. * replaces DTEND, RRULE is written. This keeps past occurrences intact when
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
* stable across a DST boundary or a zone change between the two.
*/ */
internal fun buildEventUpdateValues( internal fun buildEventUpdateValues(
original: EventForm, original: EventForm,
@@ -125,10 +167,14 @@ internal fun buildEventUpdateValues(
putAll(eventColorColumns(updated.colorKey, updated.color)) putAll(eventColorColumns(updated.colorKey, updated.color))
} }
// A zone change counts as a time change even when the wall-clock is
// untouched: the same 09:00 in another zone is a different instant, so
// DTSTART has to move with it.
val timesChanged = updated.start != original.start || val timesChanged = updated.start != original.start ||
updated.end != original.end || updated.end != original.end ||
updated.isAllDay != original.isAllDay || updated.isAllDay != original.isAllDay ||
updated.rrule != original.rrule updated.rrule != original.rrule ||
updated.timezone != original.timezone
if (!timesChanged) return@buildMap if (!timesChanged) return@buildMap
val newTimes = updated.toWriteTimes(zone) val newTimes = updated.toWriteTimes(zone)
@@ -140,8 +186,23 @@ internal fun buildEventUpdateValues(
put(CalendarContract.Events.RRULE, null) put(CalendarContract.Events.RRULE, null)
put(CalendarContract.Events.DURATION, null) put(CalendarContract.Events.DURATION, null)
} else { } else {
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis // Move the series anchor by the *wall-clock* shift the user applied to the
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta) // displayed occurrence, then re-resolve it in the event's (possibly new)
// zone — never by a millisecond delta. An instant delta silently bakes in
// the offset that happened to apply on the edited occurrence's date, which
// is a different offset from the series anchor's whenever a DST boundary
// sits between them, or whenever the zone itself changed. Working in wall
// clock keeps "09:00" meaning 09:00 at both ends.
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
.atZone(original.writeZone(zone)).toLocalDateTime()
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
val shifted = seriesLocal.plus(wallClockShift)
// An all-day series anchor must sit on a UTC midnight. A pure day move
// already lands there (both ends are midnights), but *switching* a
// recurring event to all-day shifts by a time-of-day too, so snap.
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
put(CalendarContract.Events.DTEND, null) put(CalendarContract.Events.DTEND, null)
put(CalendarContract.Events.RRULE, updated.rrule) put(CalendarContract.Events.RRULE, updated.rrule)
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay)) put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
@@ -182,6 +243,121 @@ internal fun buildOccurrenceExceptionValues(
putAll(eventColorColumns(form.colorKey, form.color)) putAll(eventColorColumns(form.colorKey, form.color))
} }
/**
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
* is sync-adapter-owned and can't be updated in place). Recurring rows carry
* [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis];
* one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw
* `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target
* account, so the moved copy inherits the target calendar's colour instead.
*/
internal data class MasterEventSnapshot(
val title: String,
val isAllDay: Boolean,
val dtStartMillis: Long,
val dtEndMillis: Long?,
val duration: String?,
val rrule: String?,
val rdate: String?,
val exdate: String?,
val timezone: String?,
val availability: Int,
val accessLevel: Int,
val status: Int?,
val location: String?,
val description: String?,
)
/**
* Column values re-creating [snapshot] as a fresh Events row on
* [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity
* survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION,
* RDATE/EXDATE) so the series' generated instances — and therefore the
* ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The
* caller layers the user's field edits on top with a normal series update.
*/
internal fun buildMovedMasterValues(
snapshot: MasterEventSnapshot,
targetCalendarId: Long,
uid: String,
): Map<String, Any?> = buildMap {
put(CalendarContract.Events.CALENDAR_ID, targetCalendarId)
put(CalendarContract.Events.UID_2445, uid)
put(CalendarContract.Events.TITLE, snapshot.title)
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
if (snapshot.rrule != null) {
put(CalendarContract.Events.RRULE, snapshot.rrule)
snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) }
snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) }
put(CalendarContract.Events.DURATION, snapshot.movedDuration())
} else {
snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) }
}
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
}
/** The recurring copy's DURATION: its own if present, else derived from DTEND. */
private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() }
?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay)
/**
* Raw provider snapshot of one exception row of a recurring series (a modified
* or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis]
* ties it to the occurrence it overrides; a [isCancelled] row only needs that.
*/
internal data class ExceptionRowSnapshot(
val exceptionEventId: Long,
val originalInstanceMillis: Long,
val isCancelled: Boolean,
val status: Int?,
val title: String,
val isAllDay: Boolean,
val dtStartMillis: Long,
val dtEndMillis: Long?,
val duration: String?,
val timezone: String?,
val availability: Int,
val accessLevel: Int,
val location: String?,
val description: String?,
)
/**
* Column values replaying a *modified* occurrence [snapshot] against the moved
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
* length travels as DURATION (the provider rejects DTEND on an exception). A
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
* STATUS_CANCELED) — this builder is only for the modified case.
*/
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
buildMap {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
put(CalendarContract.Events.TITLE, snapshot.title)
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
put(
CalendarContract.Events.DURATION,
snapshot.duration?.takeIf { it.isNotBlank() }
?: rfc2445Duration(
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
snapshot.isAllDay,
),
)
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
}
/** /**
* Column values for a *cancelled*-occurrence exception row ("delete only this * Column values for a *cancelled*-occurrence exception row ("delete only this
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the * event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the

View File

@@ -19,6 +19,7 @@ internal object CalendarProjection {
// uses to recognise its own managed calendars, independent of any // uses to recognise its own managed calendars, independent of any
// stored preference id (which a backup restore / data wipe can lose). // stored preference id (which a backup restore / data wipe can lose).
MANAGED_MARKER_COLUMN, MANAGED_MARKER_COLUMN,
CalendarContract.Calendars.SYNC_EVENTS,
) )
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1 const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
@@ -36,6 +37,7 @@ internal object CalendarProjection {
const val IDX_ACCESS_LEVEL = 6 const val IDX_ACCESS_LEVEL = 6
const val IDX_DESCRIPTION = 7 const val IDX_DESCRIPTION = 7
const val IDX_MANAGED_MARKER = 8 const val IDX_MANAGED_MARKER = 8
const val IDX_SYNC_EVENTS = 9
} }
internal object InstanceProjection { internal object InstanceProjection {
@@ -190,6 +192,86 @@ internal object SearchProjection {
const val IDX_RDATE = 11 const val IDX_RDATE = 11
} }
/**
* The master/one-off Events row of an event about to be moved to another
* calendar, read for a verbatim re-insert (see [MasterEventSnapshot]). Carries
* the full recurrence skeleton (RRULE/DURATION, RDATE/EXDATE) so the moved copy
* generates the same instances, and `UID_2445` so identity survives the move.
*/
internal object MoveMasterProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events.UID_2445,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.RRULE,
CalendarContract.Events.RDATE,
CalendarContract.Events.EXDATE,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.STATUS,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION,
)
const val IDX_UID = 0
const val IDX_TITLE = 1
const val IDX_DTSTART = 2
const val IDX_DTEND = 3
const val IDX_DURATION = 4
const val IDX_RRULE = 5
const val IDX_RDATE = 6
const val IDX_EXDATE = 7
const val IDX_EVENT_TIMEZONE = 8
const val IDX_ALL_DAY = 9
const val IDX_AVAILABILITY = 10
const val IDX_ACCESS_LEVEL = 11
const val IDX_STATUS = 12
const val IDX_LOCATION = 13
const val IDX_DESCRIPTION = 14
}
/**
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
* `DELETED = 0` so provider tombstones aren't replayed.
*/
internal object ExceptionProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
CalendarContract.Events.STATUS,
CalendarContract.Events.TITLE,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION,
)
const val IDX_ID = 0
const val IDX_ORIGINAL_INSTANCE_TIME = 1
const val IDX_STATUS = 2
const val IDX_TITLE = 3
const val IDX_ALL_DAY = 4
const val IDX_DTSTART = 5
const val IDX_DTEND = 6
const val IDX_DURATION = 7
const val IDX_EVENT_TIMEZONE = 8
const val IDX_AVAILABILITY = 9
const val IDX_ACCESS_LEVEL = 10
const val IDX_LOCATION = 11
const val IDX_DESCRIPTION = 12
}
internal object AttendeeProjection { internal object AttendeeProjection {
val COLUMNS: Array<String> = arrayOf( val COLUMNS: Array<String> = arrayOf(
CalendarContract.Attendees.ATTENDEE_NAME, CalendarContract.Attendees.ATTENDEE_NAME,

View File

@@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton import javax.inject.Singleton
@@ -46,9 +46,9 @@ abstract class DataBindModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindReminderAlertStore( abstract fun bindReminderInstanceSource(
impl: AndroidReminderAlertStore, impl: ProviderReminderInstanceSource,
): ReminderAlertStore ): ReminderInstanceSource
@Binds @Binds
@Singleton @Singleton

View File

@@ -1,18 +1,25 @@
package de.jeanlucmakiola.calendula.data.prefs package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* App-side preference for "calendars the user has hidden in this app", * App-side calendar preferences. [hiddenCalendarIds] is the drawer's filter
* separate from the system's per-calendar VISIBLE flag. * sheet — a purely in-app declutter that deliberately does *not* suppress
* reminders. Switching a calendar off entirely is the system's
* `Calendars.VISIBLE` flag, written straight to the provider (#75);
* [pendingDisabledCalendarIds] only holds those switch-offs the app has not been
* allowed to write yet.
* *
* Persisted as a comma-separated string of Long ids; non-numeric tokens are * Persisted as a comma-separated string of Long ids; non-numeric tokens are
* silently dropped (defensive — see CalendarPrefsTest). * silently dropped (defensive — see CalendarPrefsTest).
@@ -22,46 +29,56 @@ class CalendarPrefs @Inject constructor(
private val store: DataStore<Preferences>, private val store: DataStore<Preferences>,
) { ) {
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs -> // Both id sets are deduped: the store is shared with SettingsPrefs, so any
prefs[HIDDEN_IDS_KEY].orEmpty() // unrelated write would otherwise re-emit an identical set.
.split(',') val hiddenCalendarIds: Flow<Set<Long>> = store.data
.mapNotNull { it.trim().toLongOrNull() } .map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
.toSet() .distinctUntilChanged()
}
suspend fun setHiddenCalendarIds(ids: Set<Long>) { suspend fun setHiddenCalendarIds(ids: Set<Long>) {
store.edit { prefs -> store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
if (ids.isEmpty()) {
prefs.remove(HIDDEN_IDS_KEY)
} else {
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
} }
/**
* Switch-offs the provider does not know about yet, because writing
* `Calendars.VISIBLE` needs `WRITE_CALENDAR` (#75). Also inherits the
* retired app-local model's set, from the same key.
*
* Honoured as a display and reminder filter while non-empty, but not a
* second visibility model: `CalendarVisibilityReconciler` drains it entry by
* entry as soon as the app may write, and nothing adds to it while it may.
*/
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
.distinctUntilChanged()
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) =
editPendingDisabled { it + ids }
/**
* Drop [ids] from the pending set, one at a time as the reconciler flushes
* them, so a run that fails part-way never re-applies what already landed.
*/
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
editPendingDisabled { it - ids.toSet() }
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
store.edit { prefs ->
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
} }
} }
/** /**
* App-side preference for "calendars the user has disabled in this app" — a * Whether the one-time "visibility follows this device" notice is still
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from * owed. Null until the reconciler has evaluated it (which needs the calendar
* every surface (drawer filter, event-form picker, import picker) and its * permission), false once it has been shown or was never needed.
* events never appear; it stays listed only in Settings → Calendars so it can
* be re-enabled. Stored exactly like the hidden set; never touches the
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
*/ */
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs -> val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
prefs[DISABLED_IDS_KEY].orEmpty() prefs[VISIBILITY_NOTICE_KEY]
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
} }
suspend fun setDisabledCalendarIds(ids: Set<Long>) { suspend fun setVisibilityNoticePending(pending: Boolean) {
store.edit { prefs -> store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
if (ids.isEmpty()) {
prefs.remove(DISABLED_IDS_KEY)
} else {
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
}
}
} }
/** /**
@@ -79,6 +96,16 @@ class CalendarPrefs @Inject constructor(
companion object { companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids") internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids") internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id") internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
} }
} }
private fun String?.parseIds(): Set<Long> = orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
}

View File

@@ -0,0 +1,36 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* How far reminder delivery has got — the one number replacing the provider's
* `CalendarAlerts.STATE` (#75). A scan posts the reminders falling after this
* watermark and up to now, then moves it to now, so a scan running twice cannot
* post twice while a late one still catches up.
*
* Unset means "never scanned", not zero: the first scan after an install would
* otherwise treat every reminder since the epoch as overdue.
*/
@Singleton
class ReminderStatePrefs @Inject constructor(
private val store: DataStore<Preferences>,
) {
/** The watermark, or `null` before the first scan has ever run. */
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
suspend fun setLastScanMillis(millis: Long) {
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
}
private companion object {
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
}
}

View File

@@ -21,9 +21,19 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
import de.jeanlucmakiola.calendula.ui.common.storageValue
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.widget.WidgetSize
import java.time.ZoneId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import java.time.temporal.WeekFields import java.time.temporal.WeekFields
import java.util.Locale import java.util.Locale
@@ -81,6 +91,24 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value) WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
} }
/**
* The resolved first day of the week as a hot [StateFlow] — the one shape every
* screen that orders or lays out weekdays should use (the month grid, the agenda
* "this week" range, the recurrence weekday toggles). Sharing it keeps the
* initial-frame value identical everywhere; hand-rolled copies had drifted onto
* different `initialValue`s, so two surfaces could disagree for a frame.
*/
fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow<DayOfWeek> =
weekStart
.map { it.resolveFirstDay(Locale.getDefault()) }
.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5_000L),
// Seed with the locale convention rather than a hardcoded weekday, so
// the first frame is already right for everyone still on Auto.
initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()),
)
/** /**
* Display settings (M4) persisted app-side: theme override, Material You * Display settings (M4) persisted app-side: theme override, Material You
* dynamic colour, and week start. Language is handled separately through * dynamic colour, and week start. Language is handled separately through
@@ -114,6 +142,21 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DYNAMIC_COLOR_KEY] = enabled } store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
} }
/**
* Whether raw provider colours are softened toward theme-fitting pastels
* before display (issue #36). Defaults to ON — the historical look, which
* caps harsh sync colours and pins brightness so entries read on both
* themes. Turning it off paints the calendar/event colour exactly as the
* sync source (DAVx5/CalDAV) publishes it.
*/
val softenCalendarColors: Flow<Boolean> = store.data.map { prefs ->
prefs[SOFTEN_COLORS_KEY] ?: true
}
suspend fun setSoftenCalendarColors(enabled: Boolean) {
store.edit { it[SOFTEN_COLORS_KEY] = enabled }
}
/** /**
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque * Custom-font tokens per Material typeface role (issue #19). Stored as opaque
* strings — "system", "custom", or a bundled font's token — resolved to a * strings — "system", "custom", or a bundled font's token — resolved to a
@@ -218,6 +261,46 @@ class SettingsPrefs @Inject constructor(
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled } store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
} }
/**
* How the Month view lays itself out (#38, #53). Defaults to [MonthViewStyle.Paged]
* — the historical behaviour, so existing installs see no change until they opt in.
*/
val monthViewStyle: Flow<MonthViewStyle> = store.data.map { prefs ->
prefs[MONTH_VIEW_STYLE_KEY].toEnum(MonthViewStyle.Paged)
}
suspend fun setMonthViewStyle(style: MonthViewStyle) {
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
}
/**
* How tall an hour is drawn in the week and day timelines (#56). Defaults to
* [TimelineScale.Regular] — the historical 56dp scale. Holds either a preset
* or the height a pinch on the timeline settled at.
*/
val timelineScale: Flow<TimelineScale> = store.data.map { prefs ->
parseTimelineScale(prefs[TIMELINE_SCALE_KEY])
}
suspend fun setTimelineScale(scale: TimelineScale) {
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
}
/**
* Where the jump-to-today control lives (issue #60). Default OFF — the
* historical layout, where it's an extended FAB that fades in above the "+"
* only while the view is off today. Turning it ON moves it to a persistent
* icon button in each calendar view's top bar (always shown, on today or
* not) and drops the FAB pill.
*/
val todayButtonInToolbar: Flow<Boolean> = store.data.map { prefs ->
prefs[TODAY_BUTTON_IN_TOOLBAR_KEY] ?: false
}
suspend fun setTodayButtonInToolbar(enabled: Boolean) {
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
}
/** /**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to * How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the * [AgendaRange.Month] — a month of upcoming events. Independent of the
@@ -240,6 +323,23 @@ class SettingsPrefs @Inject constructor(
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() } store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
} }
/**
* The size step the agenda widget draws its text at (#51). Defaults to
* [WidgetSize.SMALL], which reproduces its original metrics, so an existing
* widget is unchanged until its owner turns the size up.
*
* This replaced deriving a size tier from the widget's measured size, which
* the launcher does not report reliably. The month widget takes no size
* setting — it divides the width it is given by seven (#103).
*/
val widgetSize: Flow<WidgetSize> = store.data.map { prefs ->
prefs[WIDGET_SIZE_KEY].toEnum(WidgetSize.SMALL)
}
suspend fun setWidgetSize(size: WidgetSize) {
store.edit { it[WIDGET_SIZE_KEY] = size.name }
}
/** /**
* Whether the agenda shows its top range bar — the "showing …" header and * Whether the agenda shows its top range bar — the "showing …" header and
* the session range switcher (v2.11). Default ON. * the session range switcher (v2.11). Default ON.
@@ -252,6 +352,20 @@ class SettingsPrefs @Inject constructor(
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled } store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
} }
/**
* Whether the agenda (both the in-app screen and the home-screen widget)
* always anchors today at the top with a "nothing left today" placeholder,
* even once today has no remaining events (issue #35). Default ON — makes
* today's events easy to tell apart from a future day's at a glance.
*/
val agendaShowToday: Flow<Boolean> = store.data.map { prefs ->
prefs[AGENDA_SHOW_TODAY_KEY] ?: true
}
suspend fun setAgendaShowToday(enabled: Boolean) {
store.edit { it[AGENDA_SHOW_TODAY_KEY] = enabled }
}
/** /**
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] — * The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
* the historical hard-coded startup view — so existing users see no change * the historical hard-coded startup view — so existing users see no change
@@ -325,6 +439,26 @@ class SettingsPrefs @Inject constructor(
} }
} }
/**
* Zones the user picked recently, most recent first — the timezone picker's
* default list, since scrolling ~600 zones to re-find the two you actually
* use is the whole problem. Stored comma-joined (IANA ids never contain a
* comma); unparseable ids are dropped on read, so a zone the tz database
* later retires can't wedge the list.
*/
val recentTimeZones: Flow<List<String>> = store.data.map { prefs ->
parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])
}
suspend fun addRecentTimeZone(zoneId: String) {
store.edit { prefs ->
val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]))
.distinct()
.take(MAX_RECENT_TIME_ZONES)
prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",")
}
}
/** /**
* Whether opening the new-event form focuses the title field and raises the * Whether opening the new-event form focuses the title field and raises the
* keyboard straight away (issue #10). Default ON — a new event almost always * keyboard straight away (issue #10). Default ON — a new event almost always
@@ -339,6 +473,46 @@ class SettingsPrefs @Inject constructor(
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled } store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
} }
/**
* How long a new **timed** event lasts, in minutes (#54). Defaults to
* [DEFAULT_EVENT_DURATION] — the historical fixed hour. Per-calendar
* overrides in [perCalendarEventDuration] take precedence; all-day events are
* date-anchored and ignore it. Resolve with [resolveDefaultEventDuration].
*/
val defaultEventDurationMinutes: Flow<Int> = store.data.map { prefs ->
(prefs[DEFAULT_EVENT_DURATION_KEY] ?: DEFAULT_EVENT_DURATION)
.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
}
suspend fun setDefaultEventDurationMinutes(minutes: Int) {
store.edit {
it[DEFAULT_EVENT_DURATION_KEY] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
}
}
/**
* Per-calendar overrides of [defaultEventDurationMinutes], keyed by calendar
* id: a calendar **present** in the map gives its new events that length, one
* **absent** inherits the global default (there is no "no duration", so this
* needs no `none` sentinel). Serialised as `id=minutes;id=minutes`.
*/
val perCalendarEventDuration: Flow<Map<Long, Int>> = store.data.map { prefs ->
parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY])
}
/** [minutes] null drops the override, so the calendar inherits the global default. */
suspend fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
store.edit { prefs ->
val current = parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY]).toMutableMap()
if (minutes == null) {
current.remove(calendarId)
} else {
current[calendarId] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
}
prefs[CALENDAR_EVENT_DURATION_KEY] = current.toStoredDurations()
}
}
/** /**
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON — * Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
* for users whose only calendar app this is, reminders are essential; the * for users whose only calendar app this is, reminders are essential; the
@@ -621,6 +795,13 @@ class SettingsPrefs @Inject constructor(
private fun titleTemplateKey(type: SpecialDateType) = private fun titleTemplateKey(type: SpecialDateType) =
stringPreferencesKey("special_dates_title_${type.name}") stringPreferencesKey("special_dates_title_${type.name}")
private fun parseRecentTimeZones(stored: String?): List<String> =
stored?.split(',').orEmpty()
.map { it.trim() }
.filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess }
.distinct()
.take(MAX_RECENT_TIME_ZONES)
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) { private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
null -> DEFAULT_FORM_FIELDS null -> DEFAULT_FORM_FIELDS
else -> stored.split(',') else -> stored.split(',')
@@ -665,6 +846,7 @@ class SettingsPrefs @Inject constructor(
companion object { companion object {
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val SOFTEN_COLORS_KEY = booleanPreferencesKey("soften_calendar_colors")
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font") internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font") internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp") internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
@@ -673,16 +855,31 @@ class SettingsPrefs @Inject constructor(
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar") internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
internal val WIDGET_SIZE_KEY = stringPreferencesKey("widget_size")
internal val AGENDA_SHOW_TODAY_KEY =
booleanPreferencesKey("agenda_show_today")
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format") internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines") internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers") internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order") internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title") internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
internal val DEFAULT_EVENT_DURATION_KEY = intPreferencesKey("default_event_duration_minutes")
internal val CALENDAR_EVENT_DURATION_KEY =
stringPreferencesKey("per_calendar_event_duration")
/** A new timed event's length until the user changes it: one hour. */
const val DEFAULT_EVENT_DURATION = 60
internal const val MIN_EVENT_DURATION = 1
/** A day — past that the form is describing a multi-day event, not a default. */
internal const val MAX_EVENT_DURATION = 1_440
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done") internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val ALLOW_COLOR_UNSUPPORTED_KEY = internal val ALLOW_COLOR_UNSUPPORTED_KEY =
@@ -704,6 +901,10 @@ class SettingsPrefs @Inject constructor(
stringPreferencesKey("per_calendar_allday_reminder_override") stringPreferencesKey("per_calendar_allday_reminder_override")
internal val DEFAULT_FORM_FIELDS = internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description) setOf(EventFormField.Location, EventFormField.Description)
internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones")
/** Enough to cover the zones a user actually recurs to, without a wall of rows. */
internal const val MAX_RECENT_TIME_ZONES = 5
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled") internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes") internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri") internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
@@ -770,6 +971,17 @@ fun resolveDefaultReminder(
} }
} }
/**
* The length a new timed event on [calendarId] opens with: that calendar's
* override if it has one, otherwise the [global] default. Pure so it can be
* unit-tested.
*/
fun resolveDefaultEventDuration(
global: Int,
overrides: Map<Long, Int>,
calendarId: Long?,
): Int = calendarId?.let { overrides[it] } ?: global
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */ /** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
private const val WEEK_START_AUTO = "AUTO" private const val WEEK_START_AUTO = "AUTO"
@@ -817,6 +1029,27 @@ private fun String?.toReminderList(): List<Int> = when {
private fun List<Int>.toStoredReminders(): String = private fun List<Int>.toStoredReminders(): String =
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() } if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
/**
* Parse the per-calendar duration map (`id=minutes` entries joined by `;`).
* Malformed entries and out-of-range lengths are dropped, so a garbled value
* degrades to "inherits the global default" instead of throwing.
*/
private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP)
if (parts.size != 2) return@mapNotNull null
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
val minutes = parts[1].trim().toIntOrNull()
?.takeIf { it in SettingsPrefs.MIN_EVENT_DURATION..SettingsPrefs.MAX_EVENT_DURATION }
?: return@mapNotNull null
calendarId to minutes
}.toMap()
private fun Map<Long, Int>.toStoredDurations(): String =
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
"$id$KEY_VALUE_SEP$minutes"
}
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E = private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -1,92 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* True when [this] alert belongs to a calendar the user disabled in-app, so its
* reminder must be suppressed (mirroring the event filtering in
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
* never treated as disabled. This is the one predicate the disabled-calendar
* gate is built from: [postableAlerts] here and the choke point in
* [ReminderNotifier.post] both use it.
*/
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
calendarId != 0L && calendarId in disabledCalendarIds
/**
* The due alerts that should actually surface as notifications: everything
* except alerts whose calendar the user has disabled in-app. The caller still
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
* provider.
*/
internal fun postableAlerts(
due: List<ReminderAlert>,
disabledCalendarIds: Set<Long>,
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
/**
* Becomes the app that turns the calendar provider's reminder alarms into
* visible notifications (the Etar model — the provider broadcasts
* `EVENT_REMINDER` at reminder time but posts nothing itself).
*
* The broadcast's data URI only carries the alarm time, so it is ignored:
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
* them, and mark them fired. Posting happens before marking — a crash in
* between re-posts silently (same tag) rather than losing the reminder.
*/
@AndroidEntryPoint
class EventReminderReceiver : BroadcastReceiver() {
@Inject lateinit var alertStore: ReminderAlertStore
@Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
@Inject lateinit var calendarPrefs: CalendarPrefs
@Inject lateinit var suppressedStore: SuppressedReminderStore
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
val readGranted = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (!readGranted || !notifier.canPost()) return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (settingsPrefs.remindersEnabled.first()) {
val now = System.currentTimeMillis()
val due = alertStore.dueAlerts(now)
val disabled = calendarPrefs.disabledCalendarIds.first()
val postable = postableAlerts(due, disabled)
// Suppress reminders for disabled calendars, but still mark
// every due alert fired so the provider stops re-broadcasting
// the suppressed ones. Stash those suppressed alerts so
// re-enabling their calendar can recover them (they would
// otherwise stay STATE_FIRED forever with no re-scan).
postable.forEach { notifier.post(it) }
alertStore.markFired(due.map { it.alertId }, now)
suppressedStore.stash(due - postable.toSet(), now)
suppressedStore.purgeExpired(now)
}
} finally {
pendingResult.finish()
}
}
}
}

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -18,12 +19,14 @@ import javax.inject.Inject
* intents (notification action buttons and our own [ReminderSnoozeScheduler] * intents (notification action buttons and our own [ReminderSnoozeScheduler]
* alarm), so the receiver is not exported. * alarm), so the receiver is not exported.
* *
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is * - **Dismiss** just cancels the notification — the scan's watermark has moved
* already fired, so nothing re-posts it. * past this reminder, so nothing re-posts it.
* - **Snooze** cancels the notification and schedules an exact alarm to re-show * - **Snooze** cancels the notification and schedules an exact alarm to re-show
* it after the user's snooze delay. * it after the user's snooze delay.
* - **Show** (the alarm) re-posts the same notification, so the user can snooze * - **Show** (the alarm) re-posts the same notification, so the user can snooze
* or dismiss it again. * or dismiss it again — unless the calendar was switched off during the
* snooze, which [ReminderNotifier.post] catches — this alarm is its own
* trigger, outside the ordinary scan.
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class ReminderActionReceiver : BroadcastReceiver() { class ReminderActionReceiver : BroadcastReceiver() {
@@ -73,7 +76,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS" const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW" const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
private const val EXTRA_ALERT_ID = "alert_id" /**
* Not handled here — the notification body opens the detail screen
* directly. It only claims a slot in [requestCode] so that intent stays
* distinct from the three this receiver does handle.
*/
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
private const val EXTRA_ALERT_KEY = "alert_key"
private const val EXTRA_EVENT_ID = "event_id" private const val EXTRA_EVENT_ID = "event_id"
private const val EXTRA_CALENDAR_ID = "calendar_id" private const val EXTRA_CALENDAR_ID = "calendar_id"
private const val EXTRA_BEGIN = "begin" private const val EXTRA_BEGIN = "begin"
@@ -82,11 +92,16 @@ class ReminderActionReceiver : BroadcastReceiver() {
private const val EXTRA_LOCATION = "location" private const val EXTRA_LOCATION = "location"
private const val EXTRA_ALL_DAY = "all_day" private const val EXTRA_ALL_DAY = "all_day"
/** An explicit intent to this receiver carrying [alert] as extras. */ /**
* An explicit intent to this receiver carrying [alert] as extras. The
* data URI duplicates no information but is what keeps two reminders'
* `PendingIntent`s apart — `filterEquals` never compares extras.
*/
fun intent(context: Context, action: String, alert: ReminderAlert): Intent = fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
Intent(context, ReminderActionReceiver::class.java).apply { Intent(context, ReminderActionReceiver::class.java).apply {
this.action = action this.action = action
putExtra(EXTRA_ALERT_ID, alert.alertId) data = "calendula://reminder/${alert.key}".toUri()
putExtra(EXTRA_ALERT_KEY, alert.key)
putExtra(EXTRA_EVENT_ID, alert.eventId) putExtra(EXTRA_EVENT_ID, alert.eventId)
putExtra(EXTRA_CALENDAR_ID, alert.calendarId) putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
putExtra(EXTRA_BEGIN, alert.beginMillis) putExtra(EXTRA_BEGIN, alert.beginMillis)
@@ -97,23 +112,25 @@ class ReminderActionReceiver : BroadcastReceiver() {
} }
/** /**
* A stable request code per (alert, action) so the three PendingIntents * A stable request code per (alert, action), so one notification's
* of one notification stay distinct and don't clobber each other. * PendingIntents stay distinct. The shift keeps the action slot intact;
* the top bits it drops are separated by [intent]'s per-reminder URI.
*/ */
fun requestCode(alert: ReminderAlert, action: String): Int { fun requestCode(alert: ReminderAlert, action: String): Int {
val actionOffset = when (action) { val actionOffset = when (action) {
ACTION_SNOOZE -> 1 ACTION_SNOOZE -> 1
ACTION_DISMISS -> 2 ACTION_DISMISS -> 2
ACTION_SHOW -> 3 ACTION_SHOW -> 3
ACTION_OPEN -> 4
else -> 0 else -> 0
} }
return alert.alertId.toInt() * 8 + actionOffset return (alert.key.toInt() shl 3) + actionOffset
} }
private fun alertFrom(intent: Intent): ReminderAlert? { private fun alertFrom(intent: Intent): ReminderAlert? {
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
return ReminderAlert( return ReminderAlert(
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L), key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L), eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L), calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L), beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),

View File

@@ -0,0 +1,64 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
* calendar app), user-revocable on 3132.
*/
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
/**
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
* exists at a time, for the earliest reminder ahead; every firing re-scans and
* re-arms. Exact, with an inexact allow-while-idle fallback where the OS
* withholds the capability (API 3132 with the permission revoked).
*/
@Singleton
class ReminderAlarmScheduler @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun scheduleScan(triggerAtMillis: Long) {
val alarmManager = context.getSystemService<AlarmManager>() ?: return
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
if (alarmManager.canScheduleExactCompat()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
}
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
fun cancelScan() {
val alarmManager = context.getSystemService<AlarmManager>() ?: return
alarmManager.cancel(scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
}
private fun scanPendingIntent(flags: Int): PendingIntent = PendingIntent.getBroadcast(
context,
SCAN_REQUEST_CODE,
Intent(context, ReminderScheduleReceiver::class.java)
.setAction(ReminderScheduleReceiver.ACTION_SCAN),
flags or PendingIntent.FLAG_IMMUTABLE,
)
private companion object {
// Fixed: there is only ever one scan alarm, and re-arming must replace it.
const val SCAN_REQUEST_CODE = 0x5CA1
}
}

View File

@@ -0,0 +1,32 @@
package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
/**
* One reminder as the notification layer needs it: what to show, and the stable
* [key] identifying it across a reboot, a re-scan and a reinstall. Derived from
* the reminder itself (see [PlannedReminder.key]) — in-house delivery has no
* `CalendarAlerts` row to take an id from (#75).
*/
data class ReminderAlert(
val key: Long,
val eventId: Long,
val calendarId: Long,
val beginMillis: Long,
val endMillis: Long,
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
val title: String,
val location: String?,
val isAllDay: Boolean,
)
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
key = key,
eventId = instance.eventId,
calendarId = instance.calendarId,
beginMillis = instance.beginMillis,
endMillis = instance.endMillis,
title = instance.title,
location = instance.location,
isAllDay = instance.isAllDay,
)

View File

@@ -1,115 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* One due row of the provider's `CalendarAlerts` table (a join with Events).
* Stays in the data layer: alerts feed the notification path only and never
* reach a screen, so there is no domain model for them.
*/
data class ReminderAlert(
val alertId: Long,
val eventId: Long,
val calendarId: Long,
val beginMillis: Long,
val endMillis: Long,
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
val title: String,
val location: String?,
val isAllDay: Boolean,
)
/**
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
* without a ContentResolver. The provider creates these rows itself — only
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
* email reminders never show up here.
*/
interface ReminderAlertStore {
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
/**
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
* which the user may have declined — then re-broadcasts silently replace
* the already-posted notifications instead (same tag, alert-once).
*/
fun markFired(alertIds: List<Long>, nowMillis: Long)
}
@Singleton
class AndroidReminderAlertStore @Inject constructor(
@ApplicationContext private val context: Context,
) : ReminderAlertStore {
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
CalendarContract.CalendarAlerts.CONTENT_URI,
PROJECTION,
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
arrayOf(
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
nowMillis.toString(),
),
CalendarContract.CalendarAlerts.BEGIN + " ASC",
)?.use { c ->
buildList {
while (c.moveToNext()) {
add(
ReminderAlert(
alertId = c.getLong(0),
eventId = c.getLong(1),
calendarId = c.getLong(2),
beginMillis = c.getLong(3),
endMillis = c.getLong(4),
title = c.getString(5).orEmpty(),
location = c.getString(6)?.takeIf { it.isNotBlank() },
isAllDay = c.getInt(7) == 1,
),
)
}
}
} ?: emptyList()
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
if (alertIds.isEmpty()) return
val values = ContentValues().apply {
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
}
try {
context.contentResolver.update(
CalendarContract.CalendarAlerts.CONTENT_URI,
values,
CalendarContract.CalendarAlerts._ID +
" IN (" + alertIds.joinToString(",") + ")",
null,
)
} catch (e: SecurityException) {
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
}
}
private companion object {
const val TAG = "ReminderAlertStore"
val PROJECTION = arrayOf(
CalendarContract.CalendarAlerts._ID,
CalendarContract.CalendarAlerts.EVENT_ID,
CalendarContract.CalendarAlerts.CALENDAR_ID,
CalendarContract.CalendarAlerts.BEGIN,
CalendarContract.CalendarAlerts.END,
CalendarContract.CalendarAlerts.TITLE,
CalendarContract.CalendarAlerts.EVENT_LOCATION,
CalendarContract.CalendarAlerts.ALL_DAY,
)
}
}

View File

@@ -0,0 +1,121 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.Context
import android.content.ContentUris
import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import javax.inject.Inject
import javax.inject.Singleton
/**
* The read side of in-house reminder delivery: occurrences and their reminder
* offsets, read from `Instances` and `Reminders` rather than `CalendarAlerts`,
* which cannot be assumed to be written (#75).
*
* An interface so [ReminderScanner] can be exercised on the JVM.
*/
interface ReminderInstanceSource {
/** Occurrences overlapping `[fromMillis, toMillis]`, of switched-on calendars. */
fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance>
/** `METHOD_ALERT` reminder offsets per event id, for the given events. */
fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>>
/**
* The largest `METHOD_ALERT` offset anywhere in the table, so the query
* window can be stretched to cover it and a long-lead reminder is planned
* before it comes due rather than firing late.
*/
fun longestReminderMinutes(): Int
}
@Singleton
class ProviderReminderInstanceSource @Inject constructor(
@ApplicationContext private val context: Context,
) : ReminderInstanceSource {
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, fromMillis)
ContentUris.appendId(this, toMillis)
}.build()
// `visible` is the flag the app's one visibility model writes (#75).
// The status clause mirrors CalendarDataSource.instances: NULL means
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
return context.contentResolver.query(
uri, OCCURRENCE_PROJECTION, selection, null, null,
)?.use { c ->
buildList {
while (c.moveToNext()) {
add(
ReminderEventInstance(
eventId = c.getLong(0),
calendarId = c.getLong(1),
beginMillis = c.getLong(2),
endMillis = if (c.isNull(3)) 0L else c.getLong(3),
title = c.getString(4).orEmpty(),
location = c.getString(5)?.takeIf { it.isNotBlank() },
isAllDay = c.getInt(6) == 1,
),
)
}
}
} ?: emptyList()
}
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
if (eventIds.isEmpty()) return emptyMap()
val out = mutableMapOf<Long, MutableList<Int>>()
// Batched: the ids go into the selection literally, and an unbounded
// `IN (...)` would grow the SQL past what SQLite takes.
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
context.contentResolver.query(
CalendarContract.Reminders.CONTENT_URI,
REMINDER_PROJECTION,
"${CalendarContract.Reminders.METHOD} = " +
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
null,
null,
)?.use { c ->
while (c.moveToNext()) {
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
}
}
}
return out
}
override fun longestReminderMinutes(): Int = context.contentResolver.query(
CalendarContract.Reminders.CONTENT_URI,
arrayOf(CalendarContract.Reminders.MINUTES),
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT}",
null,
// One row is enough: the provider passes the sort order to SQLite.
"${CalendarContract.Reminders.MINUTES} DESC",
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
private companion object {
const val EVENT_ID_BATCH = 50
val OCCURRENCE_PROJECTION = arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.ALL_DAY,
)
val REMINDER_PROJECTION = arrayOf(
CalendarContract.Reminders.EVENT_ID,
CalendarContract.Reminders.MINUTES,
)
}
}

View File

@@ -0,0 +1,62 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.util.concurrent.TimeUnit
/**
* The backstop under the alarm: a daily scan that runs whether or not the alarm
* survived. Finds nothing to do in the steady state; it exists for the device
* that quietly drops the alarm without a reboot to announce it (#75).
*/
object ReminderMaintenanceScheduler {
private const val WORK_NAME = "reminder-scan-maintenance"
/** Enqueue the daily backstop; idempotent, so every launch may call it. */
fun apply(context: Context) {
val request = PeriodicWorkRequestBuilder<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
// The launch scan covers now; let the first periodic run wait.
.setInitialDelay(1, TimeUnit.DAYS)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
}
}
class ReminderMaintenanceWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun reminderScanner(): ReminderScanner
}
override suspend fun doWork(): Result = try {
EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
.reminderScanner()
.scan()
Result.success()
} catch (e: Exception) {
// The scan swallows its own failures, so anything reaching here is the
// entry point itself — a retry will not mend it.
Log.w(TAG, "Reminder maintenance scan failed", e)
Result.success()
}
private companion object {
const val TAG = "ReminderMaintenance"
}
}

View File

@@ -14,27 +14,35 @@ import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.datetime.isoDayNumber
import java.time.DayOfWeek
import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Posts one notification per due reminder alert on a dedicated channel. * Posts one notification per due reminder on a dedicated channel. Tapping opens
* Tapping opens the event's detail screen; the tag is the alert id, so a * the event's detail screen.
* re-broadcast of an alert we couldn't mark fired replaces its notification *
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of * The tag is the reminder's stable key, so a scan that posts the same reminder
* duplicating it. * again — a catch-up pass overlapping the alarm that already fired — replaces
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
* instead of stacking a second one.
*/ */
@Singleton @Singleton
class ReminderNotifier @Inject constructor( class ReminderNotifier @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val settingsPrefs: SettingsPrefs, private val settingsPrefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs, private val calendarPrefs: CalendarPrefs,
private val calendarDataSource: CalendarDataSource,
) { ) {
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */ /** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
@@ -45,23 +53,43 @@ class ReminderNotifier @Inject constructor(
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled() return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
} }
suspend fun post(alert: ReminderAlert) { /**
// The single choke point for the disabled-calendar gate: it covers both * The single choke point for "this calendar is switched off", covering the
// the provider broadcast (EventReminderReceiver) and a snoozed re-show * two paths that reach [post] around the scan's own filter: a snooze armed
// (ReminderActionReceiver), so a calendar disabled after a snooze no * before the switch-off, and a read-only install whose switch lives in
// longer notifies — without either receiver duplicating the check. * [CalendarPrefs].
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return */
private suspend fun isSilenced(calendarId: Long): Boolean =
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarDataSource.isCalendarVisible(calendarId) == false
/**
* Post [alert], unless its calendar is switched off. Returns whether the
* notification was put up, which the snooze re-show path uses to tell a
* silenced reminder from a delivered one.
*/
suspend fun post(alert: ReminderAlert): Boolean {
if (isSilenced(alert.calendarId)) return false
ensureChannel() ensureChannel()
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) } val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first() val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context)) .is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val zone = ZoneId.systemDefault()
val locale = Locale.getDefault()
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
// its shared ISO number (1..7) for the date math in reminderTimeText.
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
val time = reminderTimeText( val time = reminderTimeText(
beginMillis = alert.beginMillis, beginMillis = alert.beginMillis,
endMillis = alert.endMillis, endMillis = alert.endMillis,
isAllDay = alert.isAllDay, isAllDay = alert.isAllDay,
zone = ZoneId.systemDefault(), zone = zone,
locale = Locale.getDefault(), locale = locale,
is24Hour = is24Hour, is24Hour = is24Hour,
today = Instant.now().atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
) )
val text = listOfNotNull(time, alert.location).joinToString(" · ") val text = listOfNotNull(time, alert.location).joinToString(" · ")
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -88,16 +116,18 @@ class ReminderNotifier @Inject constructor(
.build() .build()
try { try {
NotificationManagerCompat.from(context) NotificationManagerCompat.from(context)
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification) .notify(alert.key.toString(), NOTIFICATION_ID, notification)
} catch (e: SecurityException) { } catch (e: SecurityException) {
// POST_NOTIFICATIONS was revoked between canPost() and here. // POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e) Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
} }
// Handled either way — a retry hits the same revoked permission.
return true
} }
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */ /** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
fun cancel(alert: ReminderAlert) { fun cancel(alert: ReminderAlert) {
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID) NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
} }
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent = private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
@@ -110,7 +140,10 @@ class ReminderNotifier @Inject constructor(
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity( private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
context, context,
/* requestCode = */ alert.alertId.toInt(), // Shares the per-(alert, action) request-code scheme with the buttons.
/* requestCode = */ ReminderActionReceiver.requestCode(
alert, ReminderActionReceiver.ACTION_OPEN,
),
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis), MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )

View File

@@ -0,0 +1,158 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
/**
* One pass of in-house reminder delivery: read what is planned, post what has
* come due, and arm the next wake-up. Every trigger runs the same [scan], and
* re-running is always safe — the watermark in [ReminderStatePrefs] decides what
* is owed, not the trigger.
*/
@Singleton
class ReminderScanner @Inject constructor(
@ApplicationContext private val context: Context,
private val source: ReminderInstanceSource,
private val calendarDataSource: CalendarDataSource,
private val notifier: ReminderNotifier,
private val alarms: ReminderAlarmScheduler,
private val state: ReminderStatePrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
) {
// Triggers overlap freely (an alarm during a burst of edits); serialize so
// two passes can't both read the same watermark and post the same reminder.
private val scanLock = Mutex()
private val scope = CoroutineScope(SupervisorJob() + io)
private val providerChanges = MutableSharedFlow<Unit>(
replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private var watching = false
suspend fun scan() = withContext(io) {
scanLock.withLock {
try {
runScan()
} catch (e: SecurityException) {
// Permission revoked mid-flight; the next grant re-scans.
Log.w(TAG, "Reminder scan lacks the calendar permission", e)
} catch (e: Exception) {
Log.w(TAG, "Reminder scan failed", e)
}
}
}
private suspend fun runScan() {
val now = System.currentTimeMillis()
if (!hasReadCalendar()) return
if (!settingsPrefs.remindersEnabled.first()) {
// Reminders off: drop the wake-up, but keep the watermark moving
// so switching them back on doesn't replay the backlog.
alarms.cancelScan()
state.setLastScanMillis(now)
return
}
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
// Reach into the past too: an all-day "at time of event" encodes to a
// negative offset, and a catch-up pass needs the occurrences it missed.
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
val planned = planReminders(
instances = occurrences,
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
zone = ZoneId.systemDefault(),
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
)
val schedule = scheduleReminders(
planned = planned,
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
nowMillis = now,
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
)
if (notifier.canPost()) {
schedule.due.forEach { notifier.post(it.toAlert()) }
}
// Advance even when nothing could be posted, so muting notifications
// doesn't build a backlog.
state.setLastScanMillis(now)
alarms.scheduleScan(schedule.nextAlarmMillis)
}
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
/**
* Re-scan when the provider changes, so a saved or deleted event re-arms the
* alarm at once. Debounced, since a single save lands as several
* notifications. Process-lifetime only; other triggers cover the rest.
*/
fun startWatchingProvider() {
if (watching) return
watching = true
providerChanges
.debounce(PROVIDER_CHANGE_DEBOUNCE_MILLIS)
.onEach { scan() }
.launchIn(scope)
calendarDataSource.registerChangeListener { providerChanges.tryEmit(Unit) }
}
/** Fire-and-forget scan for callers that are not in a coroutine already. */
fun scanInBackground() {
scope.launch(start = CoroutineStart.DEFAULT) { scan() }
}
private companion object {
const val TAG = "ReminderScanner"
/**
* How far ahead occurrences are read. Stretched further by the longest
* reminder offset in the table, so this is only the floor.
*/
const val LOOKAHEAD_MILLIS = 7L * 24 * 60 * 60 * 1000
/** How far back to look for occurrences that may still owe a reminder. */
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
/**
* Never wait longer than a day for the next pass: it rolls the lookahead
* window forward and re-arms an alarm the system may have dropped.
*/
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
}
}

View File

@@ -0,0 +1,53 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Every out-of-process reason to re-run a reminder scan: our own [ACTION_SCAN]
* alarm, boot and package-replaced (both wipe pending alarms), and time or
* timezone changes (both move reminders relative to the armed alarm). All do the
* same thing, since [ReminderScanner.scan] is idempotent.
*
* Exported for the system broadcasts; an early scan triggered by another app is
* harmless.
*/
@AndroidEntryPoint
class ReminderScheduleReceiver : BroadcastReceiver() {
@Inject lateinit var scanner: ReminderScanner
override fun onReceive(context: Context, intent: Intent) {
// Checked despite every action doing the same thing: the receiver is
// exported and the broadcasts it takes are protected, so any other
// action did not come from where it claims to.
if (intent.action !in HANDLED_ACTIONS) return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
scanner.scan()
} finally {
pendingResult.finish()
}
}
}
companion object {
const val ACTION_SCAN = "de.jeanlucmakiola.calendula.reminders.SCAN"
private val HANDLED_ACTIONS = setOf(
ACTION_SCAN,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
)
}
}

View File

@@ -10,14 +10,13 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Schedules a one-off exact alarm that re-shows a snoozed reminder. * Schedules a one-off exact alarm that re-shows a snoozed reminder. Separate
* from [ReminderAlarmScheduler]'s single moving scan alarm: a snooze is pinned
* to one reminder and has to outlive the watermark moving past it, so it carries
* the reminder in its own intent.
* *
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER` * Falls back to an inexact allow-while-idle alarm where the OS withholds the
* broadcast (the Etar model), but a snoozed reminder has no provider backing — * exact-alarm capability (API 3132 with the permission revoked).
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
* back to an inexact allow-while-idle alarm only if the OS withholds the
* exact-alarm capability (API 3132 where the user revoked it).
*/ */
@Singleton @Singleton
class ReminderSnoozeScheduler @Inject constructor( class ReminderSnoozeScheduler @Inject constructor(
@@ -37,8 +36,8 @@ class ReminderSnoozeScheduler @Inject constructor(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent, AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
) )
} else { } else {
// Exact alarms revoked (API 3132): an inexact wake is the honest // Exact alarms revoked (API 3132); an inexact wake is the best
// best we can do without nagging for SCHEDULE_EXACT_ALARM. // available without nagging for SCHEDULE_EXACT_ALARM.
alarmManager.setAndAllowWhileIdle( alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent, AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
) )

View File

@@ -1,23 +1,37 @@
package de.jeanlucmakiola.calendula.data.reminders package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import java.time.DayOfWeek
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.time.temporal.ChronoUnit
import java.util.Locale import java.util.Locale
/** /**
* The one line of time context in a reminder notification. Pure so it can be * The one line of time context in a reminder notification. Pure so it can be
* JVM-tested: * JVM-tested.
* *
* - timed, same day: "09:30 10:00" * Timed events that fall on a day other than [today] are prefixed with that
* - timed, crossing days: "11 Jun, 23:30 12 Jun, 00:30" (medium date + short time) * day, so a reminder fired ahead of time no longer reads as if the event were
* today (issue #46). The prefix prefers natural language and stays short:
*
* - today: "09:30 10:00" (no prefix)
* - tomorrow / yesterday: "Tomorrow, 09:30 10:00" ([tomorrowLabel] / [yesterdayLabel])
* - elsewhere this week: "Thu, 09:30 10:00" (localized short weekday)
* - further out: "16 Jul, 09:30 10:00" (medium date — a weekday
* alone would be ambiguous)
* - timed, crossing days: "11 Jun, 23:30 12 Jun, 00:30" (medium date + short time,
* already unambiguous)
* - all-day, one day: "11 Jun 2026" * - all-day, one day: "11 Jun 2026"
* - all-day, multi-day: "11 Jun 2026 12 Jun 2026" * - all-day, multi-day: "11 Jun 2026 12 Jun 2026"
* *
* All-day instances store UTC midnights with an exclusive end, so they are * All-day instances already carry an explicit date, so they never gain a
* relative prefix. They store UTC midnights with an exclusive end, so they are
* read in UTC and the end day is the last *covered* day. * read in UTC and the end day is the last *covered* day.
*/ */
fun reminderTimeText( fun reminderTimeText(
@@ -27,6 +41,10 @@ fun reminderTimeText(
zone: ZoneId, zone: ZoneId,
locale: Locale, locale: Locale,
is24Hour: Boolean, is24Hour: Boolean,
today: LocalDate,
firstDayOfWeek: DayOfWeek,
tomorrowLabel: String,
yesterdayLabel: String,
): String { ): String {
if (isAllDay) { if (isAllDay) {
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
@@ -43,18 +61,70 @@ fun reminderTimeText(
} }
val timeFormat = timeOfDayFormatter(is24Hour, locale) val timeFormat = timeOfDayFormatter(is24Hour, locale)
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone) val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
val end = Instant.ofEpochMilli(endMillis).atZone(zone) val end = Instant.ofEpochMilli(endMillis).atZone(zone)
return if (begin.toLocalDate() == end.toLocalDate()) { return if (begin.toLocalDate() == end.toLocalDate()) {
timeFormat.format(begin) + RANGE + timeFormat.format(end) val range = timeFormat.format(begin) + RANGE + timeFormat.format(end)
val prefix = relativeDayPrefix(
day = begin.toLocalDate(),
today = today,
firstDayOfWeek = firstDayOfWeek,
locale = locale,
dateFormat = dateFormat,
tomorrowLabel = tomorrowLabel,
yesterdayLabel = yesterdayLabel,
)
if (prefix == null) range else "$prefix, $range"
} else { } else {
// Cross-day: medium date + the chosen short time, joined per side. Built // Cross-day: medium date + the chosen short time, joined per side. Built
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice // from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
// applies to the time portion too. // applies to the time portion too. The explicit dates already say which
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) // day, so no relative prefix is layered on top.
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" } val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
dateTime(begin) + RANGE + dateTime(end) dateTime(begin) + RANGE + dateTime(end)
} }
} }
/**
* A short label for [day] relative to [today], or `null` when it *is* today (the
* common case, which needs no prefix). Weekday names are used only within the
* current week — a "next Wednesday" would be indistinguishable from this one, so
* anything past this week falls back to the exact date.
*/
private fun relativeDayPrefix(
day: LocalDate,
today: LocalDate,
firstDayOfWeek: DayOfWeek,
locale: Locale,
dateFormat: DateTimeFormatter,
tomorrowLabel: String,
yesterdayLabel: String,
): String? = when (ChronoUnit.DAYS.between(today, day)) {
0L -> null
1L -> tomorrowLabel
-1L -> yesterdayLabel
else -> if (isSameWeek(day, today, firstDayOfWeek)) {
day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale)
} else {
dateFormat.format(day)
}
}
/**
* True when [day] and [today] share the same week. The week boundary honours the
* user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek],
* with [firstDayOfWeek] falling back to the locale default upstream).
*/
private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean {
val startOfWeek = today.previousOrSame(firstDayOfWeek)
return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1))
}
/** The most recent [target] on or before this date (this date itself when it matches). */
private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
val backtrack = (dayOfWeek.value - target.value + 7) % 7
return minusDays(backtrack.toLong())
}
private const val RANGE = " " private const val RANGE = " "

View File

@@ -1,130 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
/**
* Still relevant while the event has not ended: a reminder for an event that is
* already over is pointless to re-surface. Falls back to the begin time when the
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
*/
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
/**
* Local stash of reminder alerts that fired while their calendar was disabled
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
* (so the provider stops re-broadcasting the suppressed ones), which would
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
* the user re-enables the calendar before the event is over.
*
* Persisted in the shared preferences DataStore as a set of self-describing
* strings (one per alert); the stash never reaches a screen, so there is no
* domain model. Entries whose event has already ended are dropped on the next
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
*/
@Singleton
class SuppressedReminderStore @Inject constructor(
private val store: DataStore<Preferences>,
) {
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
if (alerts.isEmpty()) return
store.edit { prefs ->
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
alerts.forEach { byId[it.alertId] = it }
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
}
}
/**
* Remove and return the still-relevant stashed alerts belonging to any of
* [calendarIds]; drops expired entries for every calendar in passing.
*/
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
val recovered = mutableListOf<ReminderAlert>()
store.edit { prefs ->
val kept = decodeAll(prefs).filter { alert ->
when {
!alert.isRelevantAt(nowMillis) -> false // expired: drop
alert.calendarId in calendarIds -> { recovered += alert; false }
else -> true
}
}
prefs.putStash(kept)
}
return recovered
}
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
suspend fun purgeExpired(nowMillis: Long) {
store.edit { prefs ->
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
}
}
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
}
private companion object {
val KEY = stringSetPreferencesKey("suppressed_reminders")
}
}
// One stash entry as a delimited string. The '|' separator is safe because every
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
// a null location is stored as a distinct sentinel that Base64 also never yields.
private const val FIELD_SEP = "|"
private const val NULL_LOCATION = "-"
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
alert.alertId.toString(),
alert.eventId.toString(),
alert.calendarId.toString(),
alert.beginMillis.toString(),
alert.endMillis.toString(),
if (alert.isAllDay) "1" else "0",
alert.title.toBase64(),
alert.location?.toBase64() ?: NULL_LOCATION,
).joinToString(FIELD_SEP)
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
internal fun decodeStashEntry(raw: String): ReminderAlert? {
val parts = raw.split(FIELD_SEP)
if (parts.size != 8) return null
return try {
ReminderAlert(
alertId = parts[0].toLong(),
eventId = parts[1].toLong(),
calendarId = parts[2].toLong(),
beginMillis = parts[3].toLong(),
endMillis = parts[4].toLong(),
title = parts[6].fromBase64(),
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
isAllDay = parts[5] == "1",
)
} catch (e: NumberFormatException) {
null
} catch (e: IllegalArgumentException) { // bad Base64
null
}
}
private fun String.toBase64(): String =
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
private fun String.fromBase64(): String =
String(Base64.getDecoder().decode(this), Charsets.UTF_8)

View File

@@ -0,0 +1,60 @@
package de.jeanlucmakiola.calendula.domain
/**
* The ways a calendar can behave unlike a plain, writable one — each a reason it
* is missing from the event and import pickers (#76).
*/
enum class CalendarStateLabel {
/**
* A special-dates mirror the app fills from contacts. Writable and visible,
* yet no event target: anything authored here is deleted by the next sync.
*/
MANAGED,
/** Contents can't be modified: a WebCal subscription, a read-only share. */
READ_ONLY,
/** The account holds the events, but this device isn't syncing them down. */
NOT_SYNCED,
}
/**
* Whether the account keeps this calendar's events off the device
* (`Calendars.SYNC_EVENTS = 0`) — empty by construction. Device-local calendars
* are excluded: nothing syncs them by definition, and one from another app can
* hold real events at `sync_events = 0`.
*/
val CalendarSource.isNotSynced: Boolean
get() = !syncsEvents && !isLocal
/**
* Whether a visibility switch on this calendar can change anything the user
* would see — it can't for a non-syncing one, with no events on the device.
*/
val CalendarSource.hasVisibilitySwitch: Boolean
get() = !isNotSynced
/**
* Whether this calendar can be offered as a target for a new or imported event.
* The one predicate behind both pickers, so the states [CalendarStateLabel]
* names on a manager row are exactly the states that keep a calendar out of them
* (#76). An event already living in an excluded calendar keeps it; the editor
* adds that calendar back to its picker.
*/
val CalendarSource.isEventTarget: Boolean
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
/** Every state worth naming on this calendar's row, in reading order. */
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
if (isManaged) add(CalendarStateLabel.MANAGED)
if (!canModifyContents) add(CalendarStateLabel.READ_ONLY)
if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED)
}
/**
* Calendar-manager order within one group: the ones you can actually act on
* first, the non-syncing ones after them. Stable otherwise, so the provider's
* display-name ordering survives.
*/
fun List<CalendarSource>.orderedForManager(): List<CalendarSource> =
sortedBy { it.isNotSynced }

View File

@@ -0,0 +1,48 @@
package de.jeanlucmakiola.calendula.domain
/**
* The `Calendars.VISIBLE` writes that flush the app's pending "switched off"
* set into the provider, plus the ids that need no write at all.
*/
data class CalendarVisibilityPlan(
val hide: Set<Long> = emptySet(),
val settled: Set<Long> = emptySet(),
) {
val isEmpty: Boolean get() = hide.isEmpty() && settled.isEmpty()
}
/**
* Reconcile [pendingDisabledIds] — switch-offs the app could not write, plus
* what the retired app-local visibility model left behind (#75) — against the
* calendars actually on the device.
*
* Only ever *hides*: switching a system-hidden calendar back on would un-hide it
* in every other calendar app too. Calendula follows the flag and explains
* itself once (see [hasSystemHiddenCalendars]).
*
* [CalendarVisibilityPlan.settled] carries the ids needing no write — already
* hidden, or gone from the device.
*/
fun calendarVisibilityPlan(
calendars: List<CalendarSource>,
pendingDisabledIds: Set<Long>,
): CalendarVisibilityPlan {
val byId = calendars.associateBy { it.id }
val hide = mutableSetOf<Long>()
val settled = mutableSetOf<Long>()
for (id in pendingDisabledIds) {
val calendar = byId[id]
// Gone from the device, or already invisible — nothing to write.
if (calendar != null && calendar.isVisibleInSystem) hide += id else settled += id
}
return CalendarVisibilityPlan(hide = hide, settled = settled)
}
/**
* Whether any calendar is switched off at system level without Calendula having
* asked for it — the condition the one-time notice explains.
*/
fun hasSystemHiddenCalendars(
calendars: List<CalendarSource>,
pendingDisabledIds: Set<Long>,
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }

View File

@@ -1,12 +1,8 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import kotlin.math.abs import de.jeanlucmakiola.calendula.domain.color.Oklch
import kotlin.math.atan2 import de.jeanlucmakiola.calendula.domain.color.eventTone
import kotlin.math.cbrt import de.jeanlucmakiola.calendula.domain.color.oklchOf
import kotlin.math.hypot
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sqrt
/** /**
* Curates an account's published event palette for the colour picker. * Curates an account's published event palette for the colour picker.
@@ -18,24 +14,25 @@ import kotlin.math.sqrt
* (#22). * (#22).
* *
* Crucially, curation runs against the colour the picker actually *paints*, not * Crucially, curation runs against the colour the picker actually *paints*, not
* the raw provider value. The picker softens every swatch through [pastelArgb]: * the raw provider value — and it gets that colour from the same [eventTone]
* it pins lightness to a constant and caps saturation, so the raw palette's * the picker calls, rather than from a copy of its shaping kept in step by hand.
* lightness axis is invisible on screen. Two raw colours that look different — * Because a harmonised container pins lightness, the raw palette's lightness
* a navy and a mid blue — paint as one swatch, and every neutral (black, the * axis is invisible on screen: two raw colours that look different — a navy and
* grays, white) paints as the same pale tint. Judging distinctness in raw * a mid blue — paint as one swatch, and every neutral (black, the grays, white)
* space, as before, left near-identical painted swatches and stranded the * paints as the same grey. Judging distinctness in raw space, as before, left
* neutrals as a run of look-alike "pinks" at the end of the grid. * near-identical painted swatches and stranded the neutrals as a run of
* look-alike tints at the end of the grid.
* *
* Three steps, all in painted space: * Three steps, all in painted space:
* 1. Collapse swatches that paint identically to one (alphabetically-first key * 1. Collapse swatches that paint identically to one (alphabetically-first key
* wins, deterministically) — this folds aliases, dark/light shades of a * wins, deterministically) — this folds aliases, dark/light shades of a
* hue, and all the neutrals together. * hue, and all the neutrals together.
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out * 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the neutral-origin
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are * swatches — with lightness pinned, a grey source paints as plain grey, so
* then thinned to visually distinct colours: most vivid first, a colour is * "has no hue left" is simply zero chroma — and are then thinned to visually
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every * distinct colours: most vivid first, a colour is kept only when at least
* colour already kept. Small palettes are already curated by their adapter * [MIN_DISTANCE] away in Oklab from every colour already kept. Small
* and pass through whole. * palettes are already curated by their adapter and pass through whole.
* 3. The survivors are ordered like a rainbow — continuously by painted hue — * 3. The survivors are ordered like a rainbow — continuously by painted hue —
* with the wheel cut at its single widest empty gap so the one unavoidable * with the wheel cut at its single widest empty gap so the one unavoidable
* seam lands in dead space and no hue family is torn across both ends. * seam lands in dead space and no hue family is torn across both ends.
@@ -45,12 +42,12 @@ import kotlin.math.sqrt
*/ */
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> { fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
val painted = sortedBy { it.key } val painted = sortedBy { it.key }
.distinctBy { pastelArgb(it.argb) } .distinctBy { paintedArgb(it.argb) }
.map { it to Lab.of(pastelArgb(it.argb)) } .map { it to oklchOf(paintedArgb(it.argb)) }
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) { val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
painted painted
} else { } else {
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR }) thin(painted.filter { (_, painted) -> painted.chroma > 0f })
} }
return orderAroundWheel(kept).map { (option, _) -> option } return orderAroundWheel(kept).map { (option, _) -> option }
} }
@@ -61,17 +58,17 @@ fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
* instead of mid-family. Saturation breaks ties, vivid first. * instead of mid-family. Saturation breaks ties, vivid first.
*/ */
private fun orderAroundWheel( private fun orderAroundWheel(
swatches: List<Pair<EventColorOption, Lab>>, swatches: List<Pair<EventColorOption, Oklch>>,
): List<Pair<EventColorOption, Lab>> { ): List<Pair<EventColorOption, Oklch>> {
if (swatches.size < 2) return swatches if (swatches.size < 2) return swatches
val byHue = swatches.sortedWith( val byHue = swatches.sortedWith(
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }), compareBy({ (_, painted) -> painted.hue }, { (_, painted) -> -painted.chroma }),
) )
// Split the wheel after the largest empty arc between neighbouring hues; // Split the wheel after the largest empty arc between neighbouring hues;
// the default is the wrap gap (last hue back round to the first), i.e. the // the default is the wrap gap (last hue back round to the first), i.e. the
// familiar 0→360 order, and we only rotate away from it for a wider void. // familiar 0→360 order, and we only rotate away from it for a wider void.
var cutAfter = byHue.lastIndex var cutAfter = byHue.lastIndex
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue var widestGap = 360f - byHue.last().second.hue + byHue.first().second.hue
for (i in 0 until byHue.lastIndex) { for (i in 0 until byHue.lastIndex) {
val gap = byHue[i + 1].second.hue - byHue[i].second.hue val gap = byHue[i + 1].second.hue - byHue[i].second.hue
if (gap > widestGap) { if (gap > widestGap) {
@@ -84,102 +81,32 @@ private fun orderAroundWheel(
/** Greedy max-distance filter: vivid colours stake out clusters first. */ /** Greedy max-distance filter: vivid colours stake out clusters first. */
private fun thin( private fun thin(
swatches: List<Pair<EventColorOption, Lab>>, swatches: List<Pair<EventColorOption, Oklch>>,
): List<Pair<EventColorOption, Lab>> { ): List<Pair<EventColorOption, Oklch>> {
val byVividness = swatches val byVividness = swatches
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key }) .sortedWith(
val kept = mutableListOf<Pair<EventColorOption, Lab>>() compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
.thenBy { it.first.key },
)
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
for (candidate in byVividness) { for (candidate in byVividness) {
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate if (kept.none { it.second.distanceTo(candidate.second) < MIN_DISTANCE }) kept += candidate
} }
return kept return kept
} }
/** /** The colour the picker paints for [argb]; the light theme stands in as the
* The softening the colour picker paints over every swatch: keep the hue, scale * reference, since a harmonised container differs only in lightness by theme
* and clamp saturation into a gentle band, and pin value to a constant so * and curation compares hue and chroma. */
* nothing screams and everything reads on the surface. Value is fixed here so private fun paintedArgb(argb: Int): Int =
* curation is theme-independent — only hue and saturation distinguish painted eventTone(argb, dark = false, harmonise = true).container
* swatches.
*
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
* rather than theme-picked. Curation must reason about the colour the picker
* paints, so the two shapings have to agree: if floret's saturation band or
* curve changes, update this in step.
*/
fun pastelArgb(rawArgb: Int): Int {
val r = ((rawArgb shr 16) and 0xFF) / 255f
val g = ((rawArgb shr 8) and 0xFF) / 255f
val b = (rawArgb and 0xFF) / 255f
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val delta = max - min
val hue = when {
delta == 0f -> 0f
max == r -> 60f * (((g - b) / delta) % 6f)
max == g -> 60f * (((b - r) / delta) + 2f)
else -> 60f * (((r - g) / delta) + 4f)
}.let { if (it < 0f) it + 360f else it }
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
val s = sat.coerceIn(0.25f, 0.65f)
val v = PASTEL_VALUE
val c = v * s
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
val m = v - c
val (rr, gg, bb) = when {
hue < 60f -> Triple(c, x, 0f)
hue < 120f -> Triple(x, c, 0f)
hue < 180f -> Triple(0f, c, x)
hue < 240f -> Triple(0f, x, c)
hue < 300f -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
}
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
private const val PASTEL_VALUE = 0.82f
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */ /** Palettes at most this big skip the thinning (Google's ~26 pass through). */
private const val CURATION_TRIGGER_SIZE = 36 private const val CURATION_TRIGGER_SIZE = 36
/** Minimum CIE76 ΔE between surviving painted swatches. */
private const val MIN_DELTA_E = 13.0
/** /**
* Painted-chroma floor for oversized palettes: below this a swatch is a washed- * Minimum Oklab distance between surviving painted swatches. Painted colours all
* out tint — the neutrals and near-whites the saturation clamp muddies — so it * share one lightness, so this is really a hue/chroma separation — far enough
* is dropped rather than shown as pale filler. * apart that two swatches never read as the same colour in the grid.
*/ */
private const val PASTEL_CHROMA_FLOOR = 22.0 private const val MIN_DISTANCE = 0.025f
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
private class Lab(val l: Double, val a: Double, val b: Double) {
val chroma: Double get() = hypot(a, b)
/** Hue angle in degrees, 0360, around the Lab a-b plane. */
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
fun deltaE(other: Lab): Double =
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
companion object {
fun of(argb: Int): Lab {
fun linear(shift: Int): Double {
val c = ((argb shr shift) and 0xFF) / 255.0
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
}
val r = linear(16)
val g = linear(8)
val b = linear(0)
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
val fy = f(y)
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
}
}
}

View File

@@ -4,13 +4,14 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* User input for creating an event (and, from v1.3, editing one). Times are * User input for creating an event (and, from v1.3, editing one). Times are
* wall-clock values in the device zone; the data layer translates them to * wall-clock values in [timezone]; the data layer translates them to provider
* provider millis (all-day events normalise to UTC midnights there). * millis (all-day events normalise to UTC midnights there).
*/ */
data class EventForm( data class EventForm(
val calendarId: Long?, val calendarId: Long?,
@@ -18,6 +19,23 @@ data class EventForm(
val isAllDay: Boolean = false, val isAllDay: Boolean = false,
val start: LocalDateTime, val start: LocalDateTime,
val end: LocalDateTime, val end: LocalDateTime,
/**
* The zone [start]/[end] are wall-clock values in, or null to follow the
* device — null is not "no zone", it is "whichever zone the device is in
* when this is saved", which is what an event authored and lived in one
* place wants. The data layer resolves it at write time and always stamps a
* concrete `EVENT_TIMEZONE`.
*
* A non-null value pins the event to a zone regardless of where the device
* is, so it keeps tracking that zone's offset across DST. [toEditForm] only
* sets it when the stored zone differs from the device's, so merely opening
* a local event never reveals the field — and re-opening a pinned one in
* another zone round-trips it rather than silently re-anchoring it.
*
* Always null for all-day events: those are date-anchored, not zone-anchored
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
*/
val timezone: String? = null,
val location: String = "", val location: String = "",
val description: String = "", val description: String = "",
/** Reminder lead times in minutes before the start, deduplicated. */ /** Reminder lead times in minutes before the start, deduplicated. */
@@ -66,11 +84,19 @@ data class EventAttendee(
/** /**
* The form's optional sections. Which ones show by default is a user setting; * The form's optional sections. Which ones show by default is a user setting;
* the rest unfold behind a "more fields" button. * the rest unfold behind a "more fields" button. Declaration order is the order
* they're offered in, so a new constant goes where it belongs on the form, not
* at the end.
*/ */
enum class EventFormField { enum class EventFormField {
Location, Location,
Description, Description,
/**
* Pins the event's wall-clock times to a zone. Offered right after the time
* fields it qualifies, and suppressed entirely for all-day events, whose
* dates are deliberately zone-free.
*/
Timezone,
Reminders, Reminders,
Recurrence, Recurrence,
Availability, Availability,
@@ -100,8 +126,25 @@ enum class EventFormProblem {
* All-day provider times are UTC midnights with an exclusive end; the form * All-day provider times are UTC midnights with an exclusive end; the form
* shows the last covered day and keeps placeholder wall-clock times in case * shows the last covered day and keeps placeholder wall-clock times in case
* the user switches the event to timed. * the user switches the event to timed.
*
* A timed event stored in a zone other than [zone] is prefilled *in its own
* zone* and keeps it pinned, so the wall-clock the form shows is the one the
* event means ("the New York 09:00 call") and a later save re-anchors it to the
* same zone rather than the device's.
*/ */
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm { fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
// All-day events are date-anchored and carry a nominal "UTC" that is an
// anchor, not a location, so they never pin a zone.
val pinnedZone = if (instance.isAllDay) {
null
} else {
eventTimezone
?.takeIf { it != zone.id }
// An unparseable id (a malformed sync row) can't be honoured or
// shown; fall back to the device zone rather than failing the open.
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
}
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
val (start, end) = if (instance.isAllDay) { val (start, end) = if (instance.isAllDay) {
val startDate = Instant.fromEpochMilliseconds(beginMillis) val startDate = Instant.fromEpochMilliseconds(beginMillis)
.toLocalDateTime(TimeZone.UTC).date .toLocalDateTime(TimeZone.UTC).date
@@ -110,8 +153,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1)) val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0)) LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else { } else {
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone) Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
} }
return EventForm( return EventForm(
calendarId = instance.calendarId, calendarId = instance.calendarId,
@@ -119,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
isAllDay = instance.isAllDay, isAllDay = instance.isAllDay,
start = start, start = start,
end = end, end = end,
timezone = pinnedZone,
location = instance.location.orEmpty(), location = instance.location.orEmpty(),
description = description.orEmpty(), description = description.orEmpty(),
reminders = reminders.map { it.minutes }.distinct().sorted(), reminders = reminders.map { it.minutes }.distinct().sorted(),
@@ -176,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
rowEnd = instance.end, rowEnd = instance.end,
) )
/**
* The form's times as they land in [target] — what a pinned event's wall-clock
* actually means where the user is standing. Null when there's nothing to
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
* itself, an all-day event (no zone), or an unparseable pinned zone.
*
* The form edits a pinned event in its own zone, so this is what lets the UI
* show the other side of the pair rather than making the user do the arithmetic.
*/
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
if (isAllDay) return null
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
if (pinned.id == target.id) return null
return start.toInstant(pinned).toLocalDateTime(target) to
end.toInstant(pinned).toLocalDateTime(target)
}
/** /**
* The optional sections that hold a value in [form] — when editing, these * The optional sections that hold a value in [form] — when editing, these
* must be visible regardless of the user's default-fields setting, or the * must be visible regardless of the user's default-fields setting, or the
@@ -184,6 +245,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
fun EventForm.populatedFields(): Set<EventFormField> = buildSet { fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (location.isNotBlank()) add(EventFormField.Location) if (location.isNotBlank()) add(EventFormField.Location)
if (description.isNotBlank()) add(EventFormField.Description) if (description.isNotBlank()) add(EventFormField.Description)
if (timezone != null) add(EventFormField.Timezone)
if (reminders.isNotEmpty()) add(EventFormField.Reminders) if (reminders.isNotEmpty()) add(EventFormField.Reminders)
if (rrule != null) add(EventFormField.Recurrence) if (rrule != null) add(EventFormField.Recurrence)
if (availability != Availability.Busy) add(EventFormField.Availability) if (availability != Availability.Busy) add(EventFormField.Availability)

View File

@@ -15,7 +15,9 @@ import kotlin.time.Instant
* calendar this way to create a new event, passing the fields as * calendar this way to create a new event, passing the fields as
* [android.provider.CalendarContract] extras. Any field the intent omits falls * [android.provider.CalendarContract] extras. Any field the intent omits falls
* back to the same defaults the in-app "new event" uses — a timed start at the * back to the same defaults the in-app "new event" uses — a timed start at the
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so * next full hour, and a placeholder hour for a missing end that the form then
* stretches to the default-duration setting (the intent is opened as
* `ImportSource.InsertOpenEnded`, #54). [EventForm.calendarId] is left null so
* it resolves to the last-used / first-writable calendar, exactly like the * it resolves to the last-used / first-writable calendar, exactly like the
* `.ics` single-event and plain new-event paths. * `.ics` single-event and plain new-event paths.
* *

View File

@@ -1,5 +1,9 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant import kotlin.time.Instant
data class CalendarSource( data class CalendarSource(
@@ -8,6 +12,11 @@ data class CalendarSource(
val accountName: String, val accountName: String,
val accountType: String, val accountType: String,
val color: Int, val color: Int,
/**
* The system's `Calendars.VISIBLE` flag — the single visibility model,
* deciding both what Calendula shows and whether this calendar plans
* reminders (#75). The drawer's filter sheet is a separate in-app declutter.
*/
val isVisibleInSystem: Boolean, val isVisibleInSystem: Boolean,
/** /**
* Whether events in this calendar can be created/edited/deleted * Whether events in this calendar can be created/edited/deleted
@@ -34,6 +43,13 @@ data class CalendarSource(
* even after a backup restore clears the app's stored ids. * even after a backup restore clears the app's stored ids.
*/ */
val isManaged: Boolean = false, val isManaged: Boolean = false,
/**
* Whether the provider keeps this calendar's events on the device
* (`Calendars.SYNC_EVENTS`), independent of [isVisibleInSystem]. Says
* nothing about device-local calendars, which can hold events with it off.
* Read for the "not synced" row label (#76).
*/
val syncsEvents: Boolean = true,
) )
data class EventInstance( data class EventInstance(
@@ -56,6 +72,31 @@ data class EventInstance(
*/ */
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
/**
* The zone this event's calendar dates live in: the device [zone] for timed
* events, UTC for all-day ones, whose midnights would otherwise shift day
* boundaries (#65, #82). Every surface naming an all-day date goes through here.
*/
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
if (isAllDay) TimeZone.UTC else zone
/** The first calendar day this event occupies. */
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
start.toLocalDateTime(dateZone(zone)).date
/**
* The last calendar day this event occupies. An event ending exactly at midnight
* does not reach into that day, so resolve just before [EventInstance.end].
*/
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
val lastInstant = if (end > start) end - 1.milliseconds else start
return lastInstant.toLocalDateTime(dateZone(zone)).date
}
/** Whether this event occupies more than one calendar day in [zone]. */
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
spanFirstDay(zone) != spanLastDay(zone)
data class EventDetail( data class EventDetail(
val instance: EventInstance, val instance: EventInstance,
val description: String?, val description: String?,

View File

@@ -0,0 +1,106 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.plus
/**
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
* (DTSTART), for previewing a rule as dates instead of as words. A preview only,
* kept to the shapes the picker can build; the provider stays the authority.
*
* Mirrors RFC 5545: [start] is always the first occurrence (§3.8.5.3), even when
* the rule's own picks miss it; a monthly or yearly rule *skips* a period the
* start day doesn't exist in rather than clamping; a weekly rule repeats in
* blocks of `interval` weeks beginning on Monday (the default WKST, since
* [toRRule] never writes one); [RecurrenceEnd.Count] counts real occurrences and
* [RecurrenceEnd.Until] is inclusive.
*
* Returns fewer than [limit] dates when the series ends first, and an empty list
* only when the rule yields nothing at all (an UNTIL before [start]).
*/
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
if (limit <= 0) return emptyList()
val until = (end as? RecurrenceEnd.Until)?.date
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
val wanted = minOf(limit, maxCount)
if (wanted <= 0) return emptyList()
if (until != null && start > until) return emptyList()
// DTSTART is in the recurrence set whatever the rule picks, so seed with it
// and let the walk skip anything landing on or before it.
val result = mutableListOf(start)
var period = 0
// Periods can yield nothing (a skipped 31st), so the cap counts periods
// examined rather than dates found.
while (result.size < wanted && period < MAX_PERIODS) {
for (date in occurrencesInPeriod(period, start)) {
if (date <= start) continue
if (until != null && date > until) return result
result += date
if (result.size == wanted) return result
}
period++
}
return result
}
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
when (freq) {
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
RecurrenceFreq.Monthly -> {
val month = start.plus(period * interval, DateTimeUnit.MONTH)
// plus() clamps into the shorter month, but the rule skips such a
// period — so a clamped date means "not this month".
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
}
RecurrenceFreq.Yearly ->
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
}
/**
* One weekly block: every picked weekday inside the week that begins
* `period * interval` weeks after the start's own week, in weekday order. With
* no picks the rule simply repeats the start's weekday.
*/
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
DAYS_PER_WEEK
val weekStart = start
.minus(daysIntoWeek, DateTimeUnit.DAY)
.plus(period * interval, DateTimeUnit.WEEK)
return byDays.sortedBy { it.isoDayNumber }.map { day ->
weekStart.plus(
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
DateTimeUnit.DAY,
)
}
}
/**
* Whether a run of [occurrences] starting at [start] leaves its starting year,
* i.e. whether showing them without a year would be ambiguous — a yearly rule
* would otherwise read as the same date repeated.
*/
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
runCatching { LocalDate(year, month, day) }.getOrNull()
private const val DAYS_PER_WEEK = 7
/**
* How many repetitions to examine before giving up: generous enough for the
* sparsest rule the picker can build, bounded so a rule whose occurrences all
* fall outside its own UNTIL can't spin.
*/
private const val MAX_PERIODS = 2_000

View File

@@ -0,0 +1,225 @@
package de.jeanlucmakiola.calendula.domain
import java.text.Normalizer
import java.time.Instant
import java.time.ZoneId
import java.time.format.TextStyle
import java.util.Locale
/**
* One selectable zone, resolved for display at a given instant. [id] is the IANA
* id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]);
* [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback);
* [offsetMinutes] its offset. Both the abbreviation and the offset shift with
* DST, so they're only meaningful next to the moment they were resolved for.
*
* [displayName] is the long localized name ("Central European Time"). It's kept
* for search only — matching on it lets someone type "pacific" — and is *not*
* displayed: spelled out next to the id it made the field too wide to fit.
*/
data class TimeZoneOption(
val id: String,
val displayName: String,
val shortName: String,
val offsetMinutes: Int,
) {
/** The id as the primary label, underscores undone ("America/New York"). */
val label: String get() = id.replace('_', ' ')
/** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */
val city: String get() = id.substringAfterLast('/').replace('_', ' ')
/** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
/**
* The four fields [filterTimeZones] matches on, normalized once here rather
* than per query. Normalizing is not cheap — NFD decomposition plus a combining-
* mark strip — and a search re-examines every option on every keystroke, so
* doing it at construction turns ~2400 normalizations per character into a few
* hundred plain `startsWith`/`contains` calls.
*
* Declared in the class body, so it stays out of `equals`/`hashCode`/`copy`:
* it is derived state, and two options with the same id are the same option.
*/
internal val searchKeys: TimeZoneSearchKeys = TimeZoneSearchKeys(
city = city.normalizeForSearch(),
id = id.normalizeForSearch(),
displayName = displayName.normalizeForSearch(),
shortName = shortName.normalizeForSearch(),
)
}
/** Pre-normalized match targets for one [TimeZoneOption]. */
internal data class TimeZoneSearchKeys(
val city: String,
val id: String,
val displayName: String,
val shortName: String,
)
private fun optionFor(
zone: ZoneId,
locale: Locale,
at: Instant,
regionOf: (String) -> String?,
): TimeZoneOption = TimeZoneOption(
id = zone.id,
displayName = zone.getDisplayName(TextStyle.FULL, locale),
shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf),
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
)
/**
* The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or
* a "GMT+05:30" form when no name exists.
*
* Two things make this fiddlier than it looks. First, it goes through
* java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the latter has
* no short specific-zone names and degrades every zone to a "GMT-4" form (it
* agrees with java.util only on desktop, which is why desktop can't catch it).
* Second, ICU only surfaces the short name *commonly used in the display
* locale's region* — a German-region English phone (en-DE) is shown "CEST" but
* not "EDT", and an en-US phone the reverse. So ask in the display *language*
* but the *zone's* region ("America/New_York" in en-US, "Europe/Berlin" in
* en-DE) via [regionOf]. That covers almost everything; where a region still
* yields no name (Athens in en-GR) the plain device locale sometimes does, so
* try that next; failing both, the caller shows the offset.
*
* [regionOf] maps an IANA id to an ISO 3166 region and is injected because it
* needs `android.icu`, which this pure-JVM module can't import. The default
* (no region) leaves only the device-locale path — which is all a JVM test has
* anyway, and enough there since desktop ICU isn't region-gated the same way.
*/
private fun resolveAbbreviation(
id: String,
dst: Boolean,
locale: Locale,
regionOf: (String) -> String?,
): String {
fun shortIn(loc: Locale): String =
java.util.TimeZone.getTimeZone(id).getDisplayName(dst, java.util.TimeZone.SHORT, loc)
val regional = regionOf(id)
?.takeIf { it.length == 2 }
?.let { shortIn(Locale(locale.language, it)) }
if (regional != null && !regional.looksLikeOffset()) return regional
// Either no region, or the region has no name for this zone: the device
// locale is the next-best shot, and the offset the last resort.
return shortIn(locale)
}
/** True for the offset-style names ICU returns when a zone has no abbreviation. */
private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT")
/**
* Every zone the JVM knows, resolved at [at]. [regionOf] (see
* [resolveAbbreviation]) supplies the zone's region for the abbreviation.
*
* This is ~600 entries, each costing a localized display name plus up to two ICU
* short-name lookups, so it is **not** cheap enough for the main thread — build
* it off-thread once and filter the result with [filterTimeZones] rather than
* rebuilding per keystroke.
*
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
* dropped: they're aliases the tz database keeps for compatibility, they'd
* double up the real zones in the list, and none of them is what a user means
* when they pick a place.
*/
fun timeZoneOptions(
locale: Locale = Locale.getDefault(),
at: Instant = Instant.now(),
regionOf: (String) -> String? = { null },
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
.asSequence()
.filter { it.contains('/') && !it.startsWith("SystemV/") }
.map { optionFor(ZoneId.of(it), locale, at, regionOf) }
.sortedWith(compareBy({ it.region }, { it.city }))
.toList()
/**
* Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz
* database doesn't know it — for labelling one known zone without paying to
* build the whole catalogue.
*/
fun timeZoneOptionOf(
id: String,
locale: Locale = Locale.getDefault(),
at: Instant = Instant.now(),
regionOf: (String) -> String? = { null },
): TimeZoneOption? {
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
return optionFor(zone, locale, at, regionOf)
}
/**
* The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads
* fine alone; any other offset-shaped name is normalised to our own GMT form so
* the offset isn't stated twice in ICU's spelling and ours.
*/
fun zoneDescriptor(option: TimeZoneOption): String {
val abbrev = option.shortName
return when {
abbrev == "UTC" -> "UTC"
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
}
}
/**
* [options] matching [query], best matches first; a blank query returns the list
* unchanged. Matching is accent- and case-insensitive and treats underscores as
* spaces, so "sao paulo" finds "America/Sao_Paulo".
*
* Ranking puts a city that *starts with* the query above one that merely
* contains it — typing "col" should reach Colombo before Turks_and_Caicos —
* and the id is matched ahead of the localized name so a user who knows the
* IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a
* city prefix, so typing an abbreviation surfaces every zone that shows it
* (all the CEST zones together); the abbreviation is the one resolved for the
* display region, i.e. what the row actually shows.
*/
fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZoneOption> {
val needle = query.normalizeForSearch()
if (needle.isEmpty()) return options
return options
.mapNotNull { option ->
val keys = option.searchKeys
val rank = when {
keys.city.startsWith(needle) -> 0
keys.shortName == needle -> 1
keys.displayName.startsWith(needle) -> 2
keys.shortName.startsWith(needle) -> 3
keys.city.contains(needle) -> 4
keys.id.contains(needle) -> 5
keys.displayName.contains(needle) -> 6
else -> return@mapNotNull null
}
rank to option
}
.sortedWith(compareBy({ it.first }, { it.second.city }))
.map { it.second }
}
/** Unicode combining marks — what NFD decomposition leaves an accent as. */
private val COMBINING_MARKS = Regex("\\p{Mn}+")
/**
* Lowercased, accent-stripped, underscores and slashes flattened to spaces, so
* a query types the way a place is spoken rather than the way the tz database
* spells it.
*/
private fun String.normalizeForSearch(): String =
Normalizer.normalize(this, Normalizer.Form.NFD)
.replace(COMBINING_MARKS, "")
.replace('_', ' ')
.replace('/', ' ')
.lowercase(Locale.ROOT)
.trim()
/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */
fun formatGmtOffset(offsetMinutes: Int): String {
if (offsetMinutes == 0) return "GMT"
val sign = if (offsetMinutes < 0) '-' else '+'
val abs = kotlin.math.abs(offsetMinutes)
return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60)
}

View File

@@ -0,0 +1,124 @@
package de.jeanlucmakiola.calendula.domain.color
/**
* The colours one calendar's identity resolves to in one theme.
*
* The app paints a calendar's colour in two structurally different places, and
* they want opposite things:
*
* - a **[container]** sits behind text (week and day blocks, month bars, widget
* rows, picker swatches), so it has to contrast with its own ink;
* - an **[accent]** is a mark on an ordinary app surface (day dots, agenda and
* search stripes, calendar icon tints, the detail header), so it has to
* contrast with the *surface* instead.
*
* Painting both from one colour is what made this hard to get right: deep enough
* to carry white text is too dark to see as a dot on a dark surface, and light
* enough to show up there is too pale to carry white text. Splitting them lets
* each be pinned to the lightness its job needs, while hue and chroma — the parts
* that actually say *which calendar this is* — stay shared, so the two roles
* still read as the same colour.
*/
data class EventTone(
val container: Int,
val onContainer: Int,
val accent: Int,
)
/**
* Resolve [rawArgb] for the current theme.
*
* With [harmonise] on, hue and chroma are kept and lightness is re-pinned per
* role, which is what makes the ink predictable: every container lands at the
* same perceptual lightness, so one ink colour serves every hue at ≥ 6:1. With
* it off the provider's colour is painted verbatim — the sync source's own look,
* as DAVx5/CalDAV users expect — and the ink is then chosen per colour, since
* nothing constrains what the provider sends.
*/
fun eventTone(rawArgb: Int, dark: Boolean, harmonise: Boolean): EventTone {
val opaque = rawArgb or 0xFF000000.toInt()
val raw = oklchOf(opaque)
if (!harmonise) {
return EventTone(
container = opaque,
onContainer = inkFor(raw.lightness),
accent = opaque,
)
}
// A near-grey source has no hue worth keeping, so it stays grey rather than
// being pushed to an arbitrary one; everything else is held inside a band
// that keeps calendars apart without going neon.
val chroma = if (raw.chroma < GREY_CHROMA) 0f else raw.chroma.coerceIn(MIN_CHROMA, MAX_CHROMA)
// Two poles rather than one. Forcing every hue deep is what turned the warm,
// naturally light ones muddy — a dark orange is brown and a dark yellow is
// olive, which is a fact about those hues, not a tuning miss. So a colour is
// sent to whichever pole it already sits nearer: most land deep and carry
// white ink, while genuinely light sources (yellows, creams, pale tints)
// stay light and carry dark ink, keeping the character that made them
// recognisable. Either way the colour is pulled clear of the middle, where
// neither ink reads well — which was the original fault.
val staysLight = raw.lightness >= LIGHT_POLE_THRESHOLD
val containerLightness = when {
staysLight && dark -> LIGHT_CONTAINER_LIGHTNESS_DARK
staysLight -> LIGHT_CONTAINER_LIGHTNESS_LIGHT
dark -> CONTAINER_LIGHTNESS_DARK
else -> CONTAINER_LIGHTNESS_LIGHT
}
// The accent takes no pole: it is a mark on the app's surface, so it has to
// contrast with that surface whichever way its container went.
val accentLightness = if (dark) ACCENT_LIGHTNESS_DARK else ACCENT_LIGHTNESS_LIGHT
return EventTone(
container = Oklch(containerLightness, chroma, raw.hue).toArgb(),
onContainer = inkFor(containerLightness),
accent = Oklch(accentLightness, chroma, raw.hue).toArgb(),
)
}
/**
* White or black ink for a background of the given perceptual [lightness].
*
* Derived rather than hardcoded so it stays right for raw provider colours,
* where lightness is whatever the sync source sent. For harmonised containers it
* is constant by construction — that is the point of pinning the lightness.
*/
fun inkFor(lightness: Float): Int =
if (lightness < INK_FLIP_LIGHTNESS) 0xFFFFFFFF.toInt() else 0xFF000000.toInt()
/**
* Lightness at which white ink overtakes black. Sits above the midpoint because
* lightness is perceptual: a colour has to be distinctly light before black wins.
*/
const val INK_FLIP_LIGHTNESS = 0.62f
/**
* Raw lightness at or above which a colour keeps its light character instead of
* being pushed deep. Set high enough that oranges and warm reds still go deep —
* a burnt orange reads as orange, where a dark yellow does not read as yellow —
* so only the genuinely pale sources take the light pole.
*/
const val LIGHT_POLE_THRESHOLD = 0.72f
/** Container lightness: deep enough that white ink clears 5.5:1 on every hue. */
const val CONTAINER_LIGHTNESS_LIGHT = 0.45f
/** Same in dark mode, a shade lighter so a block separates from the surface. */
const val CONTAINER_LIGHTNESS_DARK = 0.48f
/** Light-pole container: pale enough that dark ink clears 11:1 on every hue. */
const val LIGHT_CONTAINER_LIGHTNESS_LIGHT = 0.88f
/** Same in dark mode, held down a little so a pale block doesn't glare. */
const val LIGHT_CONTAINER_LIGHTNESS_DARK = 0.84f
/** Accent lightness: dark enough to read as a mark on a pale surface. */
const val ACCENT_LIGHTNESS_LIGHT = 0.55f
/** Same against a dark surface, where the mark has to be the light one. */
const val ACCENT_LIGHTNESS_DARK = 0.78f
/** Below this chroma a colour counts as grey and keeps no hue. */
const val GREY_CHROMA = 0.02f
/** Chroma band for harmonised colours: distinct, but never electric. */
const val MIN_CHROMA = 0.07f
const val MAX_CHROMA = 0.16f

View File

@@ -0,0 +1,119 @@
package de.jeanlucmakiola.calendula.domain.color
import kotlin.math.atan2
import kotlin.math.cbrt
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
/**
* A colour in Oklch — Oklab's cylindrical form: perceptual [lightness], [chroma]
* (colourfulness) and [hue] in degrees.
*
* The point of using it over HSV is that its lightness axis matches what the eye
* calls brightness. HSV's "value" does not: at a single pinned value the hues
* spread across relative luminance 0.10 (indigo) to 0.45 (yellow), which is why
* pinning value produced fills that needed different ink per hue. Pin Oklch
* lightness instead and every hue lands at the same apparent brightness, so one
* ink serves all of them.
*
* [lightness] runs 0 (black) to 1 (white); [chroma] is 0 (grey) to about 0.32 at
* the sRGB limit.
*/
data class Oklch(val lightness: Float, val chroma: Float, val hue: Float) {
/**
* Perceptual distance to [other] — plain Euclidean in Oklab, which is what
* Oklab is built for (unlike CIE Lab, where CIE76 is known to misjudge
* saturated blues).
*/
fun distanceTo(other: Oklch): Float {
val (a1, b1) = chroma * cosDeg(hue) to chroma * sinDeg(hue)
val (a2, b2) = other.chroma * cosDeg(other.hue) to other.chroma * sinDeg(other.hue)
val dl = (lightness - other.lightness).toDouble()
return sqrt(dl * dl + (a1 - a2).pow(2) + (b1 - b2).pow(2)).toFloat()
}
}
/** Read [argb]'s opaque colour as Oklch. */
fun oklchOf(argb: Int): Oklch {
val r = toLinear(((argb shr 16) and 0xFF) / 255.0)
val g = toLinear(((argb shr 8) and 0xFF) / 255.0)
val b = toLinear((argb and 0xFF) / 255.0)
val l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
val m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
val s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
val lightness = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
val aAxis = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
val bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
val hue = (Math.toDegrees(atan2(bAxis, aAxis)) + 360.0) % 360.0
return Oklch(lightness.toFloat(), hypot(aAxis, bAxis).toFloat(), hue.toFloat())
}
/**
* The opaque sRGB colour for this Oklch, gamut-mapped: most of the Oklch cylinder
* falls outside sRGB, so a colour that does not fit keeps its lightness and hue
* and gives up chroma until it does. Holding lightness is what matters here —
* it's the axis the contrast guarantees rest on.
*/
fun Oklch.toArgb(): Int {
val fitted = if (inSrgb(lightness, chroma, hue)) {
chroma
} else {
var low = 0f
var high = chroma
repeat(GAMUT_STEPS) {
val mid = (low + high) / 2f
if (inSrgb(lightness, mid, hue)) low = mid else high = mid
}
low
}
val (r, g, b) = linearSrgbOf(lightness, fitted, hue)
return 0xFF shl 24 or
(channel(r) shl 16) or
(channel(g) shl 8) or
channel(b)
}
private fun linearSrgbOf(lightness: Float, chroma: Float, hue: Float): Triple<Double, Double, Double> {
val a = chroma * cosDeg(hue)
val b = chroma * sinDeg(hue)
val l = (lightness + 0.3963377774 * a + 0.2158037573 * b).pow(3)
val m = (lightness - 0.1055613458 * a - 0.0638541728 * b).pow(3)
val s = (lightness - 0.0894841775 * a - 1.2914855480 * b).pow(3)
return Triple(
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
)
}
private fun inSrgb(lightness: Float, chroma: Float, hue: Float): Boolean {
val (r, g, b) = linearSrgbOf(lightness, chroma, hue)
return r in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
g in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
b in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON)
}
private fun channel(linear: Double): Int =
(toSrgb(linear.coerceIn(0.0, 1.0)) * 255.0).toInt().coerceIn(0, 255)
private fun toLinear(c: Double): Double =
if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
private fun toSrgb(c: Double): Double =
if (c <= 0.0031308) 12.92 * c else 1.055 * c.pow(1.0 / 2.4) - 0.055
private fun cosDeg(deg: Float) = cos(Math.toRadians(deg.toDouble()))
private fun sinDeg(deg: Float) = sin(Math.toRadians(deg.toDouble()))
/** Bisection steps when pulling an out-of-gamut colour back into sRGB. */
private const val GAMUT_STEPS = 24
/** Slack for the gamut test, so rounding at the boundary doesn't reject a fit. */
private const val GAMUT_EPSILON = 1e-4

View File

@@ -0,0 +1,191 @@
package de.jeanlucmakiola.calendula.domain.reminders
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
/**
* Pure decision layer of in-house reminder delivery (#75): instances and reminder
* offsets in, fire instants out. No provider, no clock. See docs/ARCHITECTURE.md.
*/
/** An occurrence that reminders can hang off, flattened out of `Instances`. */
data class ReminderEventInstance(
val eventId: Long,
val calendarId: Long,
val beginMillis: Long,
val endMillis: Long,
val title: String,
val location: String?,
val isAllDay: Boolean,
)
/**
* One occurrence paired with one of its reminder offsets, and the instant that
* pairing has to fire at.
*/
data class PlannedReminder(
val instance: ReminderEventInstance,
val minutes: Int,
val alarmMillis: Long,
) {
/**
* Stable identity, keying the notification tag and the snooze/dismiss
* `PendingIntent`s. Survives reboot, re-scan and reinstall.
*/
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
private companion object {
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
var h = eventId * 1_000_003L
h = (h xor beginMillis) * 31L
return h + minutes
}
}
}
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
data class ReminderSchedule(
val due: List<PlannedReminder>,
val nextAlarmMillis: Long,
)
private const val MILLIS_PER_MINUTE = 60_000L
private const val MINUTES_PER_DAY = 1_440
/**
* Pair every instance with each of its event's reminder offsets.
*
* Timed occurrences fire at `begin minutes`. All-day ones read the offset only
* for *which day* it means ([allDayLeadDays]) and take the hour from
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
* Duplicate offsets in [minutesByEvent] collapse.
*/
fun planReminders(
instances: List<ReminderEventInstance>,
minutesByEvent: Map<Long, List<Int>>,
zone: ZoneId,
allDayTimeMinutes: Int,
): List<PlannedReminder> = instances.flatMap { instance ->
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
PlannedReminder(
instance = instance,
minutes = minutes,
alarmMillis = if (instance.isAllDay) {
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
} else {
instance.beginMillis - minutes * MILLIS_PER_MINUTE
},
)
}
}
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
private fun allDayDate(beginMillis: Long): LocalDate =
Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
/**
* How many whole days before its occurrence a raw all-day offset means.
*
* Our own rows fold the wanted hour into the offset, so the local date of the
* encoded instant is the answer. A plain multiple of 1440 is a foreign bare lead
* time and taken at face value instead — unless the instant lands on the hour the
* setting names (within [NAMED_HOUR_TOLERANCE_MINUTES], for DST drift), where the
* encodings collide and the tie goes to our own reading.
*
* Also used by
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] for
* display, so screen and notification agree.
*/
internal fun allDayLeadDays(
rawMinutes: Int,
startDate: LocalDate,
zone: ZoneId,
allDayTimeMinutes: Int,
): Long {
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val encoded = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE).atZone(zone)
val minutesFromNamedHour = encoded.toLocalTime().let {
val delta = (it.hour * 60 + it.minute - allDayTimeMinutes).mod(MINUTES_PER_DAY)
minOf(delta, MINUTES_PER_DAY - delta)
}
if (rawMinutes % MINUTES_PER_DAY == 0 && minutesFromNamedHour > NAMED_HOUR_TOLERANCE_MINUTES) {
return (rawMinutes / MINUTES_PER_DAY).toLong()
}
return ChronoUnit.DAYS.between(encoded.toLocalDate(), startDate)
}
/** DST drift a row written in the other phase carries, rounded up past Lord Howe's half hour. */
private const val NAMED_HOUR_TOLERANCE_MINUTES = 90
private fun allDayAlarmMillis(
beginMillis: Long,
rawMinutes: Int,
zone: ZoneId,
allDayTimeMinutes: Int,
): Long = allDayDate(beginMillis)
.minusDays(allDayLeadDays(rawMinutes, allDayDate(beginMillis), zone, allDayTimeMinutes))
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
.atZone(zone)
.toInstant()
.toEpochMilli()
/**
* Split [planned] into what is due now and when to wake up next.
*
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan
* running twice cannot post twice while a late one still catches up. Reminders
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
*/
fun scheduleReminders(
planned: List<PlannedReminder>,
lastFiredMillis: Long,
nowMillis: Long,
horizonMillis: Long,
): ReminderSchedule {
val due = planned
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
.filter { it.instance.isStillRelevant(nowMillis) }
.distinctBy { it.key }
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
val nextPending = planned
.filter { it.alarmMillis > nowMillis }
.minOfOrNull { it.alarmMillis }
return ReminderSchedule(
due = due,
nextAlarmMillis = minOf(nextPending ?: horizonMillis, horizonMillis),
)
}
/**
* Still worth showing while the occurrence has not ended. Falls back to the
* begin time when the end is unknown (0L).
*/
fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
/**
* The watermark a scan at [nowMillis] should measure against. A first-ever scan
* claims the present rather than replaying everything since the epoch; a
* watermark in the future (clock moved back) is clamped so it can't silence
* every reminder until real time catches up.
*/
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
/**
* How far ahead instances must be queried: the plain lookahead plus the longest
* reminder offset, so a "two weeks before" is planned before it comes due. The
* stretch is capped at [MAX_REMINDER_LEAD_MILLIS] — `maxReminderMinutes` is the
* largest row in the whole provider, and a nonsense one would otherwise make
* every scan expand every series over years.
*/
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
lookaheadMillis + (maxReminderMinutes * MILLIS_PER_MINUTE).coerceIn(0L, MAX_REMINDER_LEAD_MILLIS)
/** Longest reminder offset a scan stretches its query window for — one year. */
const val MAX_REMINDER_LEAD_MILLIS = 365L * 24 * 60 * 60 * 1000

View File

@@ -23,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
@@ -74,7 +75,10 @@ fun CalendarHost(
requestedImportUri: android.net.Uri? = null, requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null, requestedInsertForm: EventForm? = null,
requestedInsertSource: ImportSource = ImportSource.Insert,
onInsertConsumed: () -> Unit = {}, onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(), viewModel: CalendarHostViewModel = hiltViewModel(),
) { ) {
// Wait for the persisted default view before seeding the stack, so the app // Wait for the persisted default view before seeding the stack, so the app
@@ -86,6 +90,8 @@ fun CalendarHost(
// sensible non-empty initial values, so they're ready before the first frame. // sensible non-empty initial values, so they're ready before the first frame.
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
// Whether the jump-to-today control sits in each view's top bar or the FAB (#60).
val todayInToolbar = viewModel.todayButtonInToolbar.collectAsStateWithLifecycle().value
var viewStack by rememberSaveable(stateSaver = viewStackSaver) { var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView)) mutableStateOf(listOf(defaultView))
@@ -144,6 +150,10 @@ fun CalendarHost(
// over Settings and survives view switches. // over Settings and survives view switches.
var showCalendars by rememberSaveable { mutableStateOf(false) } var showCalendars by rememberSaveable { mutableStateOf(false) }
// Backup & restore (#69) — hoisted like the manager, being driven by the
// calendar list rather than by preferences. Reached from both.
var showBackup by rememberSaveable { mutableStateOf(false) }
// Event form (v1.2 create) — same held-key pattern as the detail screen: // Event form (v1.2 create) — same held-key pattern as the detail screen:
// [heldCreateIso] keeps the prefill date alive through the slide-out. // [heldCreateIso] keeps the prefill date alive through the slide-out.
// [createStartMinutes] is the tapped slot's start (minutes from midnight) // [createStartMinutes] is the tapped slot's start (minutes from midnight)
@@ -194,7 +204,7 @@ fun CalendarHost(
// reveals on top of whatever was open without extra dismissal. // reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) { LaunchedEffect(requestedInsertForm) {
if (requestedInsertForm != null) { if (requestedInsertForm != null) {
importFormSource = ImportSource.Insert importFormSource = requestedInsertSource
importForm = requestedInsertForm importForm = requestedInsertForm
onInsertConsumed() onInsertConsumed()
} }
@@ -206,12 +216,27 @@ fun CalendarHost(
fun dismissCoveringOverlays() { fun dismissCoveringOverlays() {
showSettings = false showSettings = false
showCalendars = false showCalendars = false
showBackup = false
detailKey = null detailKey = null
editKey = null editKey = null
importUri = null importUri = null
importForm = null importForm = null
} }
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
// widget) opens the occurrence straight in the edit form. Drop any covering
// overlay first — the edit overlay sits below Settings/import in the Box, so
// without this it would open hidden underneath them. Same held-key pattern as
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
LaunchedEffect(requestedEditKey) {
if (requestedEditKey != null) {
dismissCoveringOverlays()
heldEditKey = requestedEditKey
editKey = requestedEditKey
onEditKeyConsumed()
}
}
// A home-screen widget launch asks to open a date (→ day view), open an // A home-screen widget launch asks to open a date (→ day view), open an
// event's detail, or start a create. Handled once and cleared, mirroring // event's detail, or start a create. Handled once and cleared, mirroring
// [requestedDetailKey]. Date/event opens root the stack in the widget's own // [requestedDetailKey]. Date/event opens root the stack in the widget's own
@@ -275,8 +300,8 @@ fun CalendarHost(
// owns its own BackHandler and takes precedence). Disabled at the home view, // owns its own BackHandler and takes precedence). Disabled at the home view,
// so back there falls through to the system and exits the app. // so back there falls through to the system and exits the app.
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null || val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
editKey != null || showSettings || showCalendars || importUri != null || editKey != null || showSettings || showCalendars || showBackup ||
importForm != null importUri != null || importForm != null
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) { BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
viewStack = viewStack.dropLast(1) viewStack = viewStack.dropLast(1)
} }
@@ -302,6 +327,7 @@ fun CalendarHost(
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder, drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
CalendarView.Day -> DayScreen( CalendarView.Day -> DayScreen(
selectedView = currentView, selectedView = currentView,
@@ -313,16 +339,19 @@ fun CalendarHost(
initialDateIso = pendingDayIso, initialDateIso = pendingDayIso,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder, drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
CalendarView.Month -> MonthScreen( CalendarView.Month -> MonthScreen(
selectedView = currentView, selectedView = currentView,
onSelectView = onSelectView, onSelectView = onSelectView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder, drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
CalendarView.Agenda -> AgendaScreen( CalendarView.Agenda -> AgendaScreen(
selectedView = currentView, selectedView = currentView,
@@ -334,6 +363,7 @@ fun CalendarHost(
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder, drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
} }
} }
@@ -368,6 +398,14 @@ fun CalendarHost(
heldEditKey = key heldEditKey = key
editKey = key editKey = key
}, },
onDuplicate = { form ->
// Reuse the prefilled-create overlay: a duplicate is a
// fresh event, so it applies the default reminder like an
// in-app new event (#52).
importFormSource = ImportSource.Insert
importForm = form
detailKey = null
},
) )
} }
} }
@@ -384,6 +422,7 @@ fun CalendarHost(
initialStartMinutes = createStartMinutes ?: heldCreateMinutes, initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
onClose = { createDateIso = null }, onClose = { createDateIso = null },
onSaved = { createDateIso = null }, onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true },
) )
} }
} }
@@ -403,6 +442,7 @@ fun CalendarHost(
editKey = null editKey = null
detailKey = null detailKey = null
}, },
onManageCalendars = { showCalendars = true },
) )
} }
} }
@@ -416,18 +456,7 @@ fun CalendarHost(
SettingsScreen( SettingsScreen(
onBack = { showSettings = false }, onBack = { showSettings = false },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
) onOpenBackup = { showBackup = true },
}
// Calendar manager — slides over Settings.
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
CalendarsScreen(
onBack = { showCalendars = false },
onImport = { importUri = it; importForceMany = true },
) )
} }
@@ -438,6 +467,7 @@ fun CalendarHost(
uri = uri, uri = uri,
forceMany = importForceMany, forceMany = importForceMany,
onClose = { importUri = null }, onClose = { importUri = null },
onManageCalendars = { showCalendars = true },
onOpenSingle = { form -> onOpenSingle = { form ->
importUri = null importUri = null
importFormSource = ImportSource.File importFormSource = ImportSource.File
@@ -452,6 +482,40 @@ fun CalendarHost(
initialFormSource = importFormSource, initialFormSource = importFormSource,
onClose = { importForm = null }, onClose = { importForm = null },
onSaved = { importForm = null }, onSaved = { importForm = null },
onManageCalendars = { showCalendars = true },
)
}
// Declared last so it covers every overlay that can open it: Settings,
// both event forms, and the .ics import picker (#76).
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
CalendarsScreen(
onBack = { showCalendars = false },
onOpenBackup = { showBackup = true },
)
}
// Backup & restore — over the manager, since the manager links into it.
AnimatedVisibility(
visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
BackupScreen(
onBack = { showBackup = false },
// Restore runs the normal .ics import, and both this screen and
// the manager that can have opened it are declared above the
// import overlays — so both have to step aside.
onImport = {
importUri = it
importForceMany = true
showBackup = false
showCalendars = false
},
) )
} }
} }

View File

@@ -45,4 +45,12 @@ class CalendarHostViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS, initialValue = IMPLEMENTED_VIEWS,
) )
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
} }

View File

@@ -7,6 +7,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -21,6 +22,8 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@@ -36,7 +39,11 @@ fun RootScreen(
requestedImportUri: android.net.Uri? = null, requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null, requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
onInsertConsumed: () -> Unit = {}, onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
var hasPermission by remember { var hasPermission by remember {
@@ -46,6 +53,10 @@ fun RootScreen(
) )
} }
// A launch scan already covers the app coming up with the permission, so
// only a grant made during this session owes a re-scan.
val grantedAtLaunch = remember { hasPermission }
val lifecycle = LocalLifecycleOwner.current.lifecycle val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) { DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event -> val obs = LifecycleEventObserver { _, event ->
@@ -76,6 +87,19 @@ fun RootScreen(
// frame instead of flashing the wrong screen. // frame instead of flashing the wrong screen.
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel() val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle() val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
// One-time explainer for the switch to the device's own calendar
// visibility (#75), armed by the reconciler.
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
// Runs on entry however the permission was granted, including via
// Android's app-settings screen (caught by the ON_RESUME above).
LaunchedEffect(Unit) {
visibilityNotice.reconcile()
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
}
if (onboardingDone == true && noticePending) {
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
}
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done -> Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
when (done) { when (done) {
true -> CalendarHost( true -> CalendarHost(
@@ -87,7 +111,10 @@ fun RootScreen(
requestedImportUri = requestedImportUri, requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed, onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm, requestedInsertForm = requestedInsertForm,
requestedInsertSource = requestedInsertSource,
onInsertConsumed = onInsertConsumed, onInsertConsumed = onInsertConsumed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
) )
false -> ReminderOnboardingScreen( false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish, onFinished = reminderOnboarding::finish,

View File

@@ -1,10 +1,9 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
/** /**
@@ -87,24 +86,37 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
/** /**
* The concrete span the range covers, starting at [start] through [end] * The concrete span the range covers, starting at [start] through [end]
* (inclusive), for a human-readable header: * (inclusive), for a human-readable header. Both ends use the same day-month
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026") * order so a span never mixes "15 Jul" with "Aug 13, 2026":
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
* - [AgendaRange.ThisMonth] → month and year ("June 2026") * - [AgendaRange.ThisMonth] → month and year ("June 2026")
* - everything else → "start end" ("27 Jun 3 Jul 2026") * - everything else → "start end" ("27 Jun 3 Jul 2026"), with the start's
* year shown too only when it differs from the end's.
*
* [monthAsSpan] puts [AgendaRange.ThisMonth] on the "start end" form as well.
* The agenda's own header browses the whole month, so the month name is right
* there; the range picker previews the window an option opens *today*, which for
* "This month" is only the rest of it.
*/ */
fun agendaRangeWindowSummary( fun agendaRangeWindowSummary(
range: AgendaRange, range: AgendaRange,
start: LocalDate, start: LocalDate,
end: LocalDate, end: LocalDate,
locale: Locale, locale: Locale,
monthAsSpan: Boolean = false,
): String { ): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day) val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day) val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) val dayMonth = localizedDateFormatter(locale, "dMMM")
return when (range) { val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
AgendaRange.Day -> medium.format(javaStart) return when {
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart) range == AgendaRange.Day -> dayMonthYear.format(javaStart)
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} ${medium.format(javaEnd)}" range == AgendaRange.ThisMonth && !monthAsSpan ->
localizedDateFormatter(locale, "LLLLy").format(javaStart)
else -> {
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
"${startFmt.format(javaStart)} ${dayMonthYear.format(javaEnd)}"
}
} }
} }

View File

@@ -0,0 +1,207 @@
package de.jeanlucmakiola.calendula.ui.agenda
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.util.Locale
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
// split style can list a day with exactly the same visual language instead of
// growing a parallel set of event rows.
@Composable
internal fun AgendaDayHeader(
date: LocalDate,
today: LocalDate,
onOpenDay: (LocalDate) -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenDay(date) },
) {
Text(
text = agendaDayLabel(date, today),
style = MaterialTheme.typography.titleSmall,
color = if (date == today) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}
}
/**
* A card standing in for a day with no events — the same coffee-cup motif as the
* agenda's full-screen empty state, boxed into a card so the day keeps a visible slot
* rather than a bare header. Used for an anchored, event-less today (#35) and for
* an empty selected day in the month view's split style.
*/
@Composable
internal fun AgendaEmptyDayRow(text: String, onClick: () -> Unit) {
Card(
// Match a single event row's resting corner radius (floret groupedShape).
shape = RoundedCornerShape(22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 18.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Filled.Coffee,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.height(8.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
internal fun AgendaEventRow(
event: EventInstance,
day: LocalDate,
zone: TimeZone,
position: Position,
dimmed: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = title,
summary = agendaTimeSummary(event, day, zone),
position = position,
minHeight = 64.dp,
leading = {
Box(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(eventAccent(event.color, dark, soften)),
)
},
onClick = onClick,
)
}
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
@Composable
internal fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
val relative = when (date) {
today -> stringResource(R.string.agenda_header_today)
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
else -> null
}
val formatted = formatAgendaDate(date)
return if (relative != null) "$relative · $formatted" else formatted
}
/**
* Time line under the title: "09:00 10:00 · Location", "All day", etc.
*
* A multi-day event shows only the part relevant to [day], spelled out so each
* day reads on its own: its first day names the start ("Starts 14:00"), its last
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
* An all-day multi-day event is simply "All day" on every day it covers.
*/
@Composable
internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
val is24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val time = when (val label = agendaTimeLabel(event, day, zone)) {
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts -> stringResource(
R.string.agenda_span_starts,
formatTime(label.start, zone, is24Hour, locale),
)
is AgendaTimeLabel.Ends -> stringResource(
R.string.agenda_span_ends,
formatTime(label.end, zone, is24Hour, locale),
)
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} " +
formatTime(label.end, zone, is24Hour, locale)
}
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}
private fun formatTime(
instant: Instant,
zone: TimeZone,
is24Hour: Boolean,
locale: Locale,
): String {
val t = instant.toLocalDateTime(zone).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
}
private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
}

View File

@@ -1,9 +1,6 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -15,23 +12,21 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@@ -45,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -62,31 +55,23 @@ import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
private val zone = TimeZone.currentSystemDefault() // No file-level zone constant here on purpose: it would be fixed for the process
// lifetime and drift from the zone AgendaViewModel groups in after a device
// time-zone change. The zone travels on AgendaUiState.Success instead.
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -100,12 +85,15 @@ fun AgendaScreen(
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS, quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS, drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(), viewModel: AgendaViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -124,6 +112,7 @@ fun AgendaScreen(
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = anchor, currentDate = anchor,
drawerState = drawerState,
viewOrder = drawerViewOrder, viewOrder = drawerViewOrder,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
@@ -148,12 +137,14 @@ fun AgendaScreen(
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = viewModel::goToToday,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
CalendarFabColumn( CalendarFabColumn(
todayVisible = !isOnToday, todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.agenda_today_action), todayText = stringResource(R.string.agenda_today_action),
onToday = viewModel::goToToday, onToday = viewModel::goToToday,
onCreate = { onCreateEvent(anchor, null) }, onCreate = { onCreateEvent(anchor, null) },
@@ -170,9 +161,11 @@ fun AgendaScreen(
successState?.takeIf { it.showRangeBar }?.let { s -> successState?.takeIf { it.showRangeBar }?.let { s ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
// end aligns the selector's right edge with the top-bar view
// switcher (its 8.dp margin + the app bar's 4.dp inset).
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), .padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) { ) {
AgendaRangeBanner( AgendaRangeBanner(
range = s.range, range = s.range,
@@ -190,6 +183,7 @@ fun AgendaScreen(
AgendaContent( AgendaContent(
state = state, state = state,
pastDisplay = pastDisplay, pastDisplay = pastDisplay,
showToday = showToday,
onRetry = viewModel::goToToday, onRetry = viewModel::goToToday,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
@@ -206,6 +200,7 @@ fun AgendaScreen(
title = stringResource(R.string.settings_agenda_range), title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.agenda_range_override_hint), description = stringResource(R.string.agenda_range_override_hint),
selected = successState?.range ?: AgendaRange.Month, selected = successState?.range ?: AgendaRange.Month,
weekStart = weekStart,
onSelect = viewModel::setRangeOverride, onSelect = viewModel::setRangeOverride,
onDismiss = { showRangePicker = false }, onDismiss = { showRangePicker = false },
) )
@@ -213,9 +208,11 @@ fun AgendaScreen(
} }
/** /**
* A compact tonal pill showing the agenda's current range. Tapping it opens the * The agenda's current range, tapped to open the range picker as a session-only
* range picker as a session-only override. Filled with the primary container * override. Shares the top-bar view switcher's button shape so the two read as a
* while an override is active, so the temporary state is obvious. * family, but stays low-emphasis — a subtle neutral surface tint rather than the
* switcher's secondary container, so it doesn't compete. Fills with the primary
* container only while an override is active, to make that temporary state clear.
*/ */
@Composable @Composable
private fun AgendaRangePill( private fun AgendaRangePill(
@@ -224,43 +221,34 @@ private fun AgendaRangePill(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val container = if (isOverride) { FilledTonalButton(
onClick = onClick,
shape = MaterialTheme.shapes.large,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = if (isOverride) {
MaterialTheme.colorScheme.primaryContainer MaterialTheme.colorScheme.primaryContainer
} else { } else {
MaterialTheme.colorScheme.surfaceContainerHigh MaterialTheme.colorScheme.surfaceContainerHigh
} },
val content = if (isOverride) { contentColor = if (isOverride) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
} },
Surface( ),
color = container, modifier = modifier,
contentColor = content,
shape = RoundedCornerShape(50),
modifier = modifier.clickable(onClick = onClick),
) { ) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Icon(
imageVector = Icons.Filled.DateRange,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text( Text(
text = agendaRangeLabel(range), text = agendaRangeLabel(range),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
) )
} }
} }
}
/** /**
* A header naming the concrete window currently shown, e.g. "Showing all events * A header naming the concrete window currently shown under a "showing …" label,
* for · Today, 27 Jun 2026" / "This week, 27 Jun 3 Jul" / "This month, June 2026". * e.g. "27 Jun 2026" / "27 Jun 3 Jul 2026" / "June 2026". The range's name
* lives on the selector button beside it, so it isn't repeated here.
*/ */
@Composable @Composable
private fun AgendaRangeBanner( private fun AgendaRangeBanner(
@@ -277,8 +265,10 @@ private fun AgendaRangeBanner(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
// Just the concrete dates — the range's name ("Next 30 days") already
// sits on the selector button to the right, so repeating it here is noise.
Text( Text(
text = "${agendaRangeLabel(range)}, $window", text = window,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@@ -289,6 +279,7 @@ private fun AgendaRangeBanner(
private fun AgendaContent( private fun AgendaContent(
state: AgendaUiState, state: AgendaUiState,
pastDisplay: PastEventDisplay, pastDisplay: PastEventDisplay,
showToday: Boolean,
onRetry: () -> Unit, onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
@@ -304,7 +295,7 @@ private fun AgendaContent(
// Hiding drops finished events — and any day they leave empty; dimming // Hiding drops finished events — and any day they leave empty; dimming
// keeps them but fades the row. Recomputed each minute so events fall // keeps them but fades the row. Recomputed each minute so events fall
// away (or fade) as they end while the screen stays open. // away (or fade) as they end while the screen stays open.
val days = if (pastDisplay == PastEventDisplay.HIDE) { val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day -> state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) } val remaining = day.events.filterNot { it.hasEnded(now) }
if (remaining.isEmpty()) null else day.copy(events = remaining) if (remaining.isEmpty()) null else day.copy(events = remaining)
@@ -312,12 +303,21 @@ private fun AgendaContent(
} else { } else {
state.days state.days
} }
// Anchor today with a "nothing left today" placeholder — but only when
// the window actually starts on today; a jumped-to date has no today in
// it, so anchoring there would be misleading (#35).
val days = anchorTodayIfMissing(
days = filtered,
today = state.today,
enabled = showToday && state.anchor == state.today,
)
if (days.isEmpty()) { if (days.isEmpty()) {
AgendaEmpty(modifier) AgendaEmpty(modifier)
} else { } else {
AgendaList( AgendaList(
days = days, days = days,
today = state.today, today = state.today,
zone = state.zone,
dimPast = pastDisplay == PastEventDisplay.DIM, dimPast = pastDisplay == PastEventDisplay.DIM,
now = now, now = now,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -334,6 +334,7 @@ private fun AgendaContent(
private fun AgendaList( private fun AgendaList(
days: List<AgendaDay>, days: List<AgendaDay>,
today: LocalDate, today: LocalDate,
zone: TimeZone,
dimPast: Boolean, dimPast: Boolean,
now: Instant, now: Instant,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
@@ -349,76 +350,38 @@ private fun AgendaList(
stickyHeader(key = "header-${day.date}") { stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay) AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
} }
if (day.events.isEmpty()) {
// An anchored, event-less today (#35) — "nothing left today".
item(key = "placeholder-${day.date}") {
AgendaEmptyDayRow(
text = stringResource(R.string.agenda_no_more_today),
onClick = { onOpenDay(day.date) },
)
}
} else {
itemsIndexed( itemsIndexed(
items = day.events, items = day.events,
key = { _, event -> event.instanceId }, // Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event -> ) { index, event ->
AgendaEventRow( AgendaEventRow(
event = event, event = event,
day = day.date,
zone = zone,
position = positionOf(index, day.events.size), position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now), dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion(), modifier = animateItemMotion(),
onClick = { onEventClick(event) }, onClick = { onEventClick(event) },
) )
} }
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) } item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
} }
} }
} }
@Composable
private fun AgendaDayHeader(
date: LocalDate,
today: LocalDate,
onOpenDay: (LocalDate) -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenDay(date) },
) {
Text(
text = agendaDayLabel(date, today),
style = MaterialTheme.typography.titleSmall,
color = if (date == today) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}
}
@Composable
private fun AgendaEventRow(
event: EventInstance,
position: Position,
dimmed: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = title,
summary = agendaTimeSummary(event),
position = position,
minHeight = 64.dp,
leading = {
Box(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)),
)
},
onClick = onClick,
)
}
@Composable @Composable
private fun AgendaEmpty(modifier: Modifier = Modifier) { private fun AgendaEmpty(modifier: Modifier = Modifier) {
Column( Column(
@@ -448,6 +411,8 @@ private fun AgendaTopBar(
onCycleView: () -> Unit, onCycleView: () -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior, scrollBehavior: TopAppBarScrollBehavior,
) { ) {
TopAppBar( TopAppBar(
@@ -466,6 +431,7 @@ private fun AgendaTopBar(
} }
}, },
actions = { actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) { IconButton(onClick = onOpenSearch) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
@@ -485,42 +451,3 @@ private fun AgendaTopBar(
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
} }
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
@Composable
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
val relative = when (date) {
today -> stringResource(R.string.agenda_header_today)
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
else -> null
}
val formatted = formatAgendaDate(date)
return if (relative != null) "$relative · $formatted" else formatted
}
/** Time line under the title: "09:00 10:00 · Location", "All day", etc. */
@Composable
private fun agendaTimeSummary(event: EventInstance): String {
val time = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day)
} else {
val is24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
"${formatTime(event.start, is24Hour, locale)} ${formatTime(event.end, is24Hour, locale)}"
}
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}
private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String {
val t = instant.toLocalDateTime(zone).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
}
private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
return "$weekday, ${date.day}. $monthName ${date.year}"
}

View File

@@ -2,9 +2,43 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.plus
import kotlin.time.Instant
/**
* What an agenda row's time line should convey for an event on a given day —
* the part of a multi-day span that [day] falls in. Pure and shared so the
* agenda screen and the agenda widget label multi-day events identically; each
* surface only formats the instants into its own locale/24h string.
*/
sealed interface AgendaTimeLabel {
/** An all-day event, or a whole in-between day of a multi-day span. */
data object AllDay : AgendaTimeLabel
/** The first day of a multi-day timed event: when it begins. */
data class Starts(val start: Instant) : AgendaTimeLabel
/** The last day of a multi-day timed event: when it ends. */
data class Ends(val end: Instant) : AgendaTimeLabel
/** A single-day timed event: its startend range. */
data class Range(val start: Instant, val end: Instant) : AgendaTimeLabel
}
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
if (event.isAllDay) return AgendaTimeLabel.AllDay
val firstDay = event.spanFirstDay(zone)
val lastDay = event.spanLastDay(zone)
return when {
firstDay == lastDay -> AgendaTimeLabel.Range(event.start, event.end)
day <= firstDay -> AgendaTimeLabel.Starts(event.start)
day >= lastDay -> AgendaTimeLabel.Ends(event.end)
else -> AgendaTimeLabel.AllDay // a full in-between day
}
}
/** One calendar day with at least one event, for the agenda list. */ /** One calendar day with at least one event, for the agenda list. */
data class AgendaDay( data class AgendaDay(
@@ -15,22 +49,41 @@ data class AgendaDay(
/** /**
* Group flat [instances] into forward-looking [AgendaDay]s (only days that * Group flat [instances] into forward-looking [AgendaDay]s (only days that
* actually carry events). An event that began before [anchor] (ongoing or * actually carry events). A multi-day event surfaces on *every* day it spans,
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a * not just its first — clamped to [[anchor], [windowEnd]] so an event that began
* day, all-day events sort first, then ascending by start time, then title. * before the window (ongoing) still lists from the anchor day, and one running
* past the window stops at the last visible day. Within a day, all-day events
* sort first, then ascending by start time, then title.
* *
* Shared by the Agenda screen and the agenda home-screen widget so both group * Shared by the Agenda screen and the agenda home-screen widget so both group
* and order identically. * and order identically.
*/ */
fun groupAgendaDays( fun groupAgendaDays(
anchor: LocalDate, anchor: LocalDate,
windowEnd: LocalDate,
instances: List<EventInstance>, instances: List<EventInstance>,
zone: TimeZone, zone: TimeZone,
): List<AgendaDay> = ): List<AgendaDay> {
instances val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) } for (instance in instances) {
.toSortedMap() val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
.map { (date, dayEvents) -> val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
// Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
// The provider returns an event whenever its instant span overlaps the query
// window, but all-day events live at UTC midnights with an exclusive end: east
// of UTC that end dips just past local midnight, so *yesterday's* all-day event
// overlaps today's window start and comes back even though its true last day
// (resolved in UTC) is before the anchor. Clamping it up to the anchor would
// surface it under "today" (issue #65); drop it instead. The symmetric case —
// a next-day all-day event overlapping the window's last instant — drops too.
if (lastDay < firstDay) continue
var day = firstDay
while (day <= lastDay) {
byDay.getOrPut(day) { mutableListOf() }.add(instance)
day = day.plus(1, DateTimeUnit.DAY)
}
}
return byDay.map { (date, dayEvents) ->
AgendaDay( AgendaDay(
date = date, date = date,
events = dayEvents.sortedWith( events = dayEvents.sortedWith(
@@ -40,6 +93,25 @@ fun groupAgendaDays(
), ),
) )
} }
}
/**
* Ensure [today] surfaces as the first agenda day even when it carries no
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
* when today already has its own day in [days]. Keeps today anchored at the top
* so a glance tells today's events apart from a future day's (issue #35).
*/
fun anchorTodayIfMissing(
days: List<AgendaDay>,
today: LocalDate,
enabled: Boolean,
): List<AgendaDay> =
if (enabled && days.none { it.date == today }) {
listOf(AgendaDay(today, emptyList())) + days
} else {
days
}
/** /**
* State for the Agenda view: a flat, forward-looking list of upcoming events * State for the Agenda view: a flat, forward-looking list of upcoming events
@@ -61,5 +133,12 @@ sealed interface AgendaUiState {
val rangeEnd: LocalDate, val rangeEnd: LocalDate,
/** Whether to show the top range bar — header + switcher (toggle, on by default). */ /** Whether to show the top range bar — header + switcher (toggle, on by default). */
val showRangeBar: Boolean, val showRangeBar: Boolean,
/**
* The zone [days] were grouped in. Carried in the state rather than
* re-read by the screen so labelling and grouping cannot disagree: an
* event's "Starts …/Ends …/All day" line is only correct relative to the
* same zone that decided which day it was filed under.
*/
val zone: TimeZone,
) : AgendaUiState ) : AgendaUiState
} }

View File

@@ -7,7 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import java.util.Locale
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
import kotlinx.datetime.plus import kotlinx.datetime.plus
@@ -50,9 +48,12 @@ class AgendaViewModel @Inject constructor(
settingsPrefs.agendaShowRangeBar, settingsPrefs.agendaShowRangeBar,
) { range, showBar -> AgendaSettings(range, showBar) } ) { range, showBar -> AgendaSettings(range, showBar) }
// First day of the week, for the calendar-aligned "this week" range. /**
private val weekStartDay = settingsPrefs.weekStart * First day of the week, for the calendar-aligned "this week" range. Public
.map { it.resolveFirstDay(Locale.getDefault()) } * because the range picker resolves each option to real dates, which needs
* the same week start the window is built from.
*/
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
/** /**
* How to treat events that already ended today (show / dim / hide). A display * How to treat events that already ended today (show / dim / hide). A display
@@ -66,6 +67,19 @@ class AgendaViewModel @Inject constructor(
initialValue = PastEventDisplay.SHOW, initialValue = PastEventDisplay.SHOW,
) )
/**
* Whether to keep today anchored at the top with a "nothing left today"
* placeholder even once it has no remaining events (#35). A display concern
* applied in the composition (after past-event filtering), so it rides
* alongside the data state like [pastEventDisplay] rather than re-querying.
*/
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = true,
)
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate private val todayDate: LocalDate
@@ -80,7 +94,7 @@ class AgendaViewModel @Inject constructor(
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null) private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
val state: StateFlow<AgendaUiState> = val state: StateFlow<AgendaUiState> =
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart -> combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
AgendaParams( AgendaParams(
anchor = anchor, anchor = anchor,
range = override ?: settings.range, range = override ?: settings.range,
@@ -146,11 +160,11 @@ class AgendaViewModel @Inject constructor(
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured) return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
} }
val anchor = params.anchor val anchor = params.anchor
val days = groupAgendaDays(anchor, instances, zone)
val rangeEnd = anchor.plus( val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1, params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY, DateTimeUnit.DAY,
) )
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
return AgendaUiState.Success( return AgendaUiState.Success(
anchor = anchor, anchor = anchor,
today = todayDate, today = todayDate,
@@ -159,6 +173,7 @@ class AgendaViewModel @Inject constructor(
rangeIsOverride = params.rangeIsOverride, rangeIsOverride = params.rangeIsOverride,
rangeEnd = rangeEnd, rangeEnd = rangeEnd,
showRangeBar = params.showRangeBar, showRangeBar = params.showRangeBar,
zone = zone,
) )
} }
} }

View File

@@ -0,0 +1,373 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.net.Uri
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import java.time.LocalDate
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
// under several mimes, so accept the common set.
private val RESTORE_MIME_TYPES = arrayOf(
"text/calendar",
"application/octet-stream",
"text/plain",
)
/**
* Backup & restore (#69): `.ics` export and restore for local calendars, with
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
*
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
* [onImport] hands a picked file to the app's normal .ics import flow.
*/
@Composable
fun BackupScreen(
onBack: () -> Unit,
onImport: (Uri) -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// Export covers local calendars only; managed special-dates mirrors are
// rebuilt from contacts. Restore can target anything the import picker offers.
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
val canImport = calendars.any { it.isEventTarget }
// Exports everything eligible (null); the per-calendar selector owns its
// own launcher.
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
var showExportPicker by rememberSaveable { mutableStateOf(false) }
// Restore runs the picked file through the normal .ics import flow.
val openBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(onImport) }
// The VM persists the write grant so background runs can keep writing.
val pickFolder = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
var showInterval by remember { mutableStateOf(false) }
val backupFailedText = stringResource(R.string.calendars_backup_failed)
LaunchedEffect(backupResult) {
when (val r = backupResult) {
is BackupResult.Success -> {
snackbarHostState.showSnackbar(
context.resources.getQuantityString(
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
),
)
viewModel.consumeBackupResult()
}
BackupResult.Failure -> {
snackbarHostState.showSnackbar(backupFailedText)
viewModel.consumeBackupResult()
}
null -> Unit
}
}
CollapsingScaffold(
title = stringResource(R.string.settings_section_backup),
onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true,
) {
HintText(stringResource(R.string.calendars_backup_hint))
if (exportable.isNotEmpty()) {
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
// A single exportable calendar skips the selector.
if (exportable.size == 1) {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
} else {
showExportPicker = true
}
},
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = stringResource(R.string.calendars_restore_hint),
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
leading = { LeadingAvatar(Icons.Default.Schedule) },
trailing = {
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
},
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
)
if (autoBackup.enabled) {
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_folder),
summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle,
onClick = { runCatching { pickFolder.launch(null) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
onClick = { showInterval = true },
)
HintText(backupStatusText(autoBackup.status))
}
} else if (canImport) {
// Nothing to back up, but restore is still possible — don't hide
// it behind export eligibility.
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(stringResource(R.string.calendars_restore_hint))
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
)
}
}
if (showExportPicker) {
ExportCalendarPicker(
calendars = exportable,
onExport = viewModel::exportBackup,
onDismiss = { showExportPicker = false },
)
}
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
onConfirm = viewModel::setAutoBackupIntervalMinutes,
onDismiss = { showInterval = false },
)
}
}
/**
* Choose which local calendars to include in a one-time `.ics` export. Defaults
* to all selected; the Export action opens the SAF save dialog and hands back
* the picked file with the chosen calendar ids.
*/
@Composable
private fun ExportCalendarPicker(
calendars: List<CalendarSource>,
onExport: (Uri, Set<Long>?) -> Unit,
onDismiss: () -> Unit,
) {
// Deliberately not keyed on [calendars]: that list is observer-driven, so
// keying it would reset the user's de-selections on every provider re-emit.
var selected by rememberSaveable(
stateSaver = listSaver(
save = { it.toList() },
restore = { it.toSet() },
),
) {
mutableStateOf(calendars.map { it.id }.toSet())
}
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri ->
if (uri != null) {
onExport(uri, selected)
onDismiss()
}
}
FullScreenPicker(
title = stringResource(R.string.calendars_export_title),
onDismiss = onDismiss,
) {
HintText(stringResource(R.string.calendars_export_hint))
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) },
trailing = {
Checkbox(
checked = isSelected,
onCheckedChange = { checked ->
selected = if (checked) selected + calendar.id else selected - calendar.id
},
)
},
onClick = {
selected = if (isSelected) selected - calendar.id else selected + calendar.id
},
)
}
Button(
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
},
enabled = selected.isNotEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(stringResource(R.string.calendars_export_action))
}
}
}
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
@Composable
private fun rememberFolderName(uriString: String?): String? {
val context = LocalContext.current
return remember(uriString) {
uriString?.let {
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
}
}
}
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
@Composable
private fun backupIntervalLabel(minutes: Long): String {
val duration = when {
minutes % MINUTES_PER_WEEK == 0L ->
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
minutes % MINUTES_PER_DAY == 0L ->
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
minutes % 60L == 0L ->
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
else ->
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
}
return stringResource(R.string.calendars_auto_backup_every, duration)
}
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
@Composable
private fun backupStatusText(status: BackupStatus): String {
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
val relative = DateUtils.getRelativeTimeSpanString(
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
).toString()
return if (status.lastSuccess) {
stringResource(R.string.calendars_auto_backup_status_ok, relative)
} else {
stringResource(R.string.calendars_auto_backup_status_failed, relative)
}
}
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
@Composable
private fun BackupIntervalDialog(
currentMinutes: Long,
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// Pick the largest unit the current value divides into.
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
val units = stringArrayResource(R.array.backup_interval_units).toList()
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.calendars_auto_backup_interval_min),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = {
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
onDismiss()
}) { Text(stringResource(R.string.reminder_custom_set)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private const val MINUTES_PER_DAY = 1_440L
private const val MINUTES_PER_WEEK = 10_080L

View File

@@ -0,0 +1,71 @@
package de.jeanlucmakiola.calendula.ui.calendars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The one-time notice that Calendula now follows the device's per-calendar
* visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not
* switch those calendars back on — that would un-hide them everywhere else too.
*/
@HiltViewModel
class CalendarVisibilityNoticeViewModel @Inject constructor(
private val prefs: CalendarPrefs,
private val reconciler: CalendarVisibilityReconciler,
) : ViewModel() {
/**
* Reconcile whenever the app comes up with the calendar permission held,
* rather than off one grant route: a permission granted on Android's
* app-settings screen never reaches the permission screen's callback.
*/
fun reconcile() {
viewModelScope.launch { reconciler.run() }
}
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
.map { it == true }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
fun dismiss() {
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
}
}
/** Plain informational dialog — one acknowledgement, nothing to decide. */
@Composable
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_ok))
}
},
)
}

View File

@@ -4,9 +4,6 @@ import android.accounts.AccountManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.provider.Settings import android.provider.Settings
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -26,25 +23,22 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -66,82 +60,69 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.CalendarStateLabel
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.domain.isNotSynced
import de.jeanlucmakiola.calendula.domain.orderedForManager
import de.jeanlucmakiola.calendula.domain.stateLabels
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.AccountKey
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize
import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */ /** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE private const val NEW_CALENDAR_ID = Long.MIN_VALUE
// SAF mime filter for the restore picker. `.ics` files reach us under several
// mimes depending on the source app (our own export uses text/calendar; others
// hand them out as octet-stream or text/plain), so accept the common set rather
// than hide valid backups behind an over-tight filter.
private val RESTORE_MIME_TYPES = arrayOf(
"text/calendar",
"application/octet-stream",
"text/plain",
)
/** /**
* Calendar manager (reached from Settings). Lists the app's own device-only * Calendar manager (reached from Settings). Lists the app's own device-only
* calendars with create / rename / recolor / delete (via a full-screen editor), * calendars with create / rename / recolor / delete, and synced calendars
* and lists synced calendars read-only with a per-account "manage in the source * read-only with a per-account "manage in the source app" deep-link.
* app" deep-link — the app never touches a synced calendar's server. A *
* full-screen destination; [onBack] pops it. * Export/import lives in its own Settings entry ([BackupScreen], #69); this
* screen only points at it. [onBack] pops the destination.
*/ */
@Composable @Composable
fun CalendarsScreen( fun CalendarsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onImport: (android.net.Uri) -> Unit, onOpenBackup: () -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle() val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle() val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar. // null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
// [editorSession] bumps on every open so the editor's field state resets for // [editorSession] bumps on every open so the editor's field state resets for
@@ -157,6 +138,7 @@ fun CalendarsScreen(
initialName = editing?.displayName.orEmpty(), initialName = editing?.displayName.orEmpty(),
initialColor = editing?.color ?: CalendarColorPalette.all.first(), initialColor = editing?.color ?: CalendarColorPalette.all.first(),
initialDescription = editing?.description.orEmpty(), initialDescription = editing?.description.orEmpty(),
deleteLocked = editing != null && editing.id in deleteLockedIds,
onSave = { name, color, description -> onSave = { name, color, description ->
val id = editorId val id = editorId
if (id == null || id == NEW_CALENDAR_ID) { if (id == null || id == NEW_CALENDAR_ID) {
@@ -176,22 +158,14 @@ fun CalendarsScreen(
CalendarsList( CalendarsList(
local = calendars.filter { it.isLocal }, local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal }, synced = calendars.filterNot { it.isLocal },
disabledIds = disabledIds,
error = error, error = error,
onConsumeError = viewModel::consumeError, onConsumeError = viewModel::consumeError,
backupResult = backupResult, onOpenBackup = onOpenBackup,
onExportBackup = viewModel::exportBackup,
onImport = onImport,
onConsumeBackupResult = viewModel::consumeBackupResult,
autoBackup = autoBackup,
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
onBack = onBack, onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id }, onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetDisabled = viewModel::setDisabled, onSetVisible = viewModel::setCalendarVisible,
onSetAccountDisabled = viewModel::setAccountDisabled, onSetAccountVisible = viewModel::setAccountVisible,
) )
} }
} }
@@ -200,28 +174,20 @@ fun CalendarsScreen(
private fun CalendarsList( private fun CalendarsList(
local: List<CalendarSource>, local: List<CalendarSource>,
synced: List<CalendarSource>, synced: List<CalendarSource>,
disabledIds: Set<Long>,
error: Boolean, error: Boolean,
onConsumeError: () -> Unit, onConsumeError: () -> Unit,
backupResult: BackupResult?, onOpenBackup: () -> Unit,
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
onImport: (android.net.Uri) -> Unit,
onConsumeBackupResult: () -> Unit,
autoBackup: AutoBackupUiState,
onSetAutoBackupEnabled: (Boolean) -> Unit,
onSetAutoBackupInterval: (Long) -> Unit,
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onAdd: () -> Unit, onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit, onEdit: (CalendarSource) -> Unit,
onSetDisabled: (Long, Boolean) -> Unit, onSetVisible: (Long, Boolean) -> Unit,
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit, onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Accounts the user has folded shut; empty = all expanded (keeps every // Accounts the user has folded shut; empty = all expanded (keeps every
// calendar visible by default, the section is collapsible for tidiness). // calendar visible by default, the section is collapsible for tidiness).
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) } var collapsedAccounts by remember { mutableStateOf(emptySet<AccountKey>()) }
var localExpanded by remember { mutableStateOf(true) } var localExpanded by remember { mutableStateOf(true) }
val writeErrorText = stringResource(R.string.calendars_write_error) val writeErrorText = stringResource(R.string.calendars_write_error)
@@ -232,47 +198,6 @@ private fun CalendarsList(
} }
} }
// SAF "create document" target for the backup file. The picked Uri is handed
// to the VM to stream the .ics into. This launcher exports everything
// eligible (null); the per-calendar selector owns its own launcher.
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let { onExportBackup(it, null) } }
var showExportPicker by rememberSaveable { mutableStateOf(false) }
// SAF "open document" picker for restoring events from a .ics file. The
// picked Uri is handed up to the host, which runs it through the same import
// flow as an externally opened .ics (parse, dedup by UID, target picker).
val openBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(onImport) }
// SAF folder picker for the automatic-backup destination; the VM persists the
// write grant so background runs can keep writing to it.
val pickFolder = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri -> uri?.let(onSetAutoBackupFolder) }
var showInterval by remember { mutableStateOf(false) }
val backupFailedText = stringResource(R.string.calendars_backup_failed)
LaunchedEffect(backupResult) {
when (val r = backupResult) {
is BackupResult.Success -> {
snackbarHostState.showSnackbar(
context.resources.getQuantityString(
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
),
)
onConsumeBackupResult()
}
BackupResult.Failure -> {
snackbarHostState.showSnackbar(backupFailedText)
onConsumeBackupResult()
}
null -> Unit
}
}
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.calendars_title), title = stringResource(R.string.calendars_title),
onBack = onBack, onBack = onBack,
@@ -280,12 +205,12 @@ private fun CalendarsList(
predictiveBack = true, predictiveBack = true,
) { ) {
// What the per-calendar / per-account switches below actually do. // What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_disable_hint)) HintText(stringResource(R.string.calendars_visibility_hint))
// Local (device-only) calendars — one collapsible group. The header's // Local (device-only) calendars — one collapsible group. The header's
// "+" adds a calendar; the switch enables/disables them all at once; // "+" adds a calendar; the switch enables/disables them all at once;
// tapping a calendar row opens its editor. // tapping a calendar row opens its editor.
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds } val localDisabled = local.isNotEmpty() && local.none { it.isVisibleInSystem }
CalendarGroup( CalendarGroup(
title = stringResource(R.string.calendars_local_header), title = stringResource(R.string.calendars_local_header),
expanded = localExpanded, expanded = localExpanded,
@@ -297,17 +222,17 @@ private fun CalendarsList(
onManage = onAdd, onManage = onAdd,
onToggleExpand = { localExpanded = !localExpanded }, onToggleExpand = { localExpanded = !localExpanded },
showToggleAll = local.isNotEmpty(), showToggleAll = local.isNotEmpty(),
allEnabled = local.none { it.id in disabledIds }, allEnabled = local.all { it.isVisibleInSystem },
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) }, onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
) { ) {
if (local.isEmpty()) { if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty)) HintText(stringResource(R.string.calendars_local_empty))
} else { } else {
local.forEachIndexed { index, calendar -> local.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds val disabled = !calendar.isVisibleInSystem
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
summary = calendar.description, summary = calendarRowSummary(calendar),
position = if (index == local.lastIndex) Position.Bottom else Position.Middle, position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest, container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled, dimmed = disabled,
@@ -316,7 +241,7 @@ private fun CalendarsList(
EnableSwitch( EnableSwitch(
calendarName = calendar.displayName, calendarName = calendar.displayName,
enabled = !disabled, enabled = !disabled,
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
) )
}, },
onClick = { onEdit(calendar) }, onClick = { onEdit(calendar) },
@@ -325,82 +250,22 @@ private fun CalendarsList(
} }
} }
// Backup — local calendars have no sync, so a .ics export is their only // Pointer to the Backup entry (#69), so it stays findable from here.
// safety net. Offered only when there is something exportable: the user's
// own local calendars (managed special-dates mirrors don't count).
val exportable = local.filter { it.canModifyContents && !it.isManaged }
// Restore/import can target any writable, non-managed calendar (local or
// synced), so its availability is broader than export's.
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
if (exportable.isNotEmpty()) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header))
HintText(stringResource(R.string.calendars_backup_hint))
// One connected card: the one-time export on top, then automatic
// backup (and its folder/interval rows when on).
GroupedRow( GroupedRow(
title = stringResource(R.string.calendars_backup_action), title = stringResource(R.string.settings_section_backup),
position = Position.Top, summary = stringResource(R.string.settings_backup_subtitle),
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
// With more than one exportable calendar, let the user choose
// which to include; a single one exports straight away.
if (exportable.size == 1) {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
} else {
showExportPicker = true
}
},
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = stringResource(R.string.calendars_restore_hint),
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = {
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
},
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
leading = { LeadingAvatar(Icons.Default.Schedule) },
trailing = {
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
},
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
)
if (autoBackup.enabled) {
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_folder),
summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle,
onClick = { runCatching { pickFolder.launch(null) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
onClick = { showInterval = true },
)
HintText(backupStatusText(autoBackup.status))
}
} else if (canImport) {
// Nothing to back up (no writable local calendar), but events can
// still be restored into a writable calendar — offer restore on its
// own so it isn't hidden behind export eligibility.
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(stringResource(R.string.calendars_restore_hint))
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone, position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) }, leading = { LeadingAvatar(Icons.Default.Backup) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onOpenBackup,
) )
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -410,14 +275,19 @@ private fun CalendarsList(
SectionHeader(stringResource(R.string.calendars_synced_header)) SectionHeader(stringResource(R.string.calendars_synced_header))
HintText(stringResource(R.string.calendars_synced_hint)) HintText(stringResource(R.string.calendars_synced_hint))
synced synced
.groupBy { it.accountName.ifBlank { it.accountType } } .groupByAccount()
.forEach { (account, cals) -> .forEach { group ->
val expanded = account !in collapsedAccounts val cals = group.calendars
val accountType = cals.first().accountType val expanded = group.key !in collapsedAccounts
val accountDisabled = cals.all { it.id in disabledIds } val accountType = group.accountType
// A non-syncing calendar has no switch, so it neither counts
// towards "the whole account is off" nor moves with toggle-all.
val switchable = cals.filter { it.hasVisibilitySwitch }
val accountDisabled = switchable.isNotEmpty() &&
switchable.none { it.isVisibleInSystem }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
CalendarGroup( CalendarGroup(
title = account, title = accountGroupTitle(group),
expanded = expanded, expanded = expanded,
bodyHasRows = true, bodyHasRows = true,
headerDisabled = accountDisabled, headerDisabled = accountDisabled,
@@ -429,29 +299,41 @@ private fun CalendarsList(
}, },
onToggleExpand = { onToggleExpand = {
collapsedAccounts = if (expanded) { collapsedAccounts = if (expanded) {
collapsedAccounts + account collapsedAccounts + group.key
} else { } else {
collapsedAccounts - account collapsedAccounts - group.key
} }
}, },
showToggleAll = true, showToggleAll = switchable.isNotEmpty(),
allEnabled = cals.none { it.id in disabledIds }, allEnabled = switchable.all { it.isVisibleInSystem },
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) }, onToggleAll = { enabled ->
onSetAccountVisible(switchable.map { it.id }, enabled)
},
) { ) {
cals.forEachIndexed { index, calendar -> // Actionable calendars first; non-syncing ones at the
val disabled = calendar.id in disabledIds // bottom, dimmed and switchless.
val ordered = cals.orderedForManager()
ordered.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle, summary = calendarRowSummary(calendar),
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest, container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled, dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = { trailing = if (calendar.hasVisibilitySwitch) {
{
EnableSwitch( EnableSwitch(
calendarName = calendar.displayName, calendarName = calendar.displayName,
enabled = !disabled, enabled = calendar.isVisibleInSystem,
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, onToggle = { enabled ->
onSetVisible(calendar.id, enabled)
},
) )
}
} else {
null
}, },
) )
} }
@@ -459,93 +341,6 @@ private fun CalendarsList(
} }
} }
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
onConfirm = onSetAutoBackupInterval,
onDismiss = { showInterval = false },
)
}
if (showExportPicker) {
ExportCalendarPicker(
calendars = local.filter { it.canModifyContents && !it.isManaged },
onExport = onExportBackup,
onDismiss = { showExportPicker = false },
)
}
}
/**
* Choose which local calendars to include in a one-time `.ics` export. Defaults
* to all selected; the Export action opens the SAF save dialog and hands back
* the picked file with the chosen calendar ids.
*/
@Composable
private fun ExportCalendarPicker(
calendars: List<CalendarSource>,
onExport: (android.net.Uri, Set<Long>?) -> Unit,
onDismiss: () -> Unit,
) {
// Seed once with everything selected and hold it across recomposition and
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
// it would silently reset the user's de-selections whenever the provider
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
// — the data layer intersects the chosen set with the eligible calendars.
var selected by rememberSaveable(
stateSaver = listSaver(
save = { it.toList() },
restore = { it.toSet() },
),
) {
mutableStateOf(calendars.map { it.id }.toSet())
}
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri ->
if (uri != null) {
onExport(uri, selected)
onDismiss()
}
}
FullScreenPicker(
title = stringResource(R.string.calendars_export_title),
onDismiss = onDismiss,
) {
HintText(stringResource(R.string.calendars_export_hint))
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) },
trailing = {
Checkbox(
checked = isSelected,
onCheckedChange = { checked ->
selected = if (checked) selected + calendar.id else selected - calendar.id
},
)
},
onClick = {
selected = if (isSelected) selected - calendar.id else selected + calendar.id
},
)
}
Button(
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
},
enabled = selected.isNotEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(stringResource(R.string.calendars_export_action))
}
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -559,12 +354,14 @@ private fun CalendarEditor(
onSave: (name: String, color: Int, description: String?) -> Unit, onSave: (name: String, color: Int, description: String?) -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
deleteLocked: Boolean = false,
) { ) {
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) } var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) } var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) } var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
var confirmDelete by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
@@ -591,11 +388,20 @@ private fun CalendarEditor(
}, },
actions = { actions = {
if (!isNew) { if (!isNew) {
IconButton(onClick = { confirmDelete = true }) { // Disabled rather than hidden while the special-dates
// sync owns this calendar; the card below says why.
IconButton(
onClick = { confirmDelete = true },
enabled = !deleteLocked,
) {
Icon( Icon(
Icons.Default.Delete, Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete), contentDescription = stringResource(R.string.event_detail_delete),
tint = MaterialTheme.colorScheme.error, tint = if (deleteLocked) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else {
MaterialTheme.colorScheme.error
},
) )
} }
} }
@@ -624,7 +430,20 @@ private fun CalendarEditor(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) { if (deleteLocked) {
EditorCard(
icon = Icons.Default.Info,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
Text(
text = stringResource(R.string.calendars_managed_delete_locked),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
InlineTextField( InlineTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
@@ -696,10 +515,28 @@ private fun CalendarEditor(
} }
/** /**
* The per-row enable/disable control. Checked = the calendar is shown in the * The row's supporting line: the states that make this calendar behave unlike a
* app; unchecking disables it (events, filters and pickers all drop it) without * plain writable one (#76), then its own description.
* touching any provider data. Carries its own content description so the toggle */
* is self-describing to screen readers even on a dimmed row. @Composable
private fun calendarRowSummary(calendar: CalendarSource): String? {
val states = calendar.stateLabels().map { label ->
stringResource(
when (label) {
CalendarStateLabel.MANAGED -> R.string.calendars_state_managed
CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only
CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced
},
)
}
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
return parts.joinToString(" · ").ifEmpty { null }
}
/**
* The per-row on/off control, writing the system's device-local
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
* stops its reminders. Carries its own content description.
*/ */
@Composable @Composable
private fun EnableSwitch( private fun EnableSwitch(
@@ -707,7 +544,7 @@ private fun EnableSwitch(
enabled: Boolean, enabled: Boolean,
onToggle: (Boolean) -> Unit, onToggle: (Boolean) -> Unit,
) { ) {
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName) val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch( Switch(
checked = enabled, checked = enabled,
onCheckedChange = onToggle, onCheckedChange = onToggle,
@@ -881,113 +718,31 @@ private fun CalendarGroupMenu(
@Composable @Composable
private fun SectionHeader(text: String) { internal fun SectionHeader(text: String) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), // The cards' own edge, so header and group share a left margin.
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
) )
} }
@Composable @Composable
private fun HintText(text: String) { internal fun HintText(text: String) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
) )
} }
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
@Composable
private fun rememberFolderName(uriString: String?): String? {
val context = LocalContext.current
return remember(uriString) {
uriString?.let {
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
}
}
}
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
@Composable
private fun backupIntervalLabel(minutes: Long): String {
val duration = when {
minutes % MINUTES_PER_WEEK == 0L ->
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
minutes % MINUTES_PER_DAY == 0L ->
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
minutes % 60L == 0L ->
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
else ->
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
}
return stringResource(R.string.calendars_auto_backup_every, duration)
}
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
@Composable
private fun backupStatusText(status: BackupStatus): String {
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
val relative = DateUtils.getRelativeTimeSpanString(
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
).toString()
return if (status.lastSuccess) {
stringResource(R.string.calendars_auto_backup_status_ok, relative)
} else {
stringResource(R.string.calendars_auto_backup_status_failed, relative)
}
}
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
@Composable
private fun BackupIntervalDialog(
currentMinutes: Long,
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
val units = stringArrayResource(R.array.backup_interval_units).toList()
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.calendars_auto_backup_interval_min),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = {
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
onDismiss()
}) { Text(stringResource(R.string.reminder_custom_set)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private const val MINUTES_PER_DAY = 1_440L
private const val MINUTES_PER_WEEK = 10_080L
/** /**
* Pick the app to open for managing a synced calendar's account. The account's * Pick the app to open for managing a synced calendar's account. The account's

View File

@@ -12,10 +12,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -45,10 +42,7 @@ class CalendarsViewModel @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val icsExporter: IcsExporter, private val icsExporter: IcsExporter,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs, private val settingsPrefs: SettingsPrefs,
private val suppressedStore: SuppressedReminderStore,
private val notifier: ReminderNotifier,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -62,20 +56,6 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptyList(), initialValue = emptyList(),
) )
/**
* Calendars the user has disabled in the app. This screen is the only
* surface that lists them, so it both reads the set (to dim the rows) and
* toggles it. Every other surface simply excludes these ids.
*/
val disabledCalendarIds: StateFlow<Set<Long>> =
prefs.disabledCalendarIds
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
/** Automatic-backup settings + last-run status, for the Backup section UI. */ /** Automatic-backup settings + last-run status, for the Backup section UI. */
val autoBackup: StateFlow<AutoBackupUiState> = combine( val autoBackup: StateFlow<AutoBackupUiState> = combine(
settingsPrefs.autoBackupEnabled, settingsPrefs.autoBackupEnabled,
@@ -92,6 +72,25 @@ class CalendarsViewModel @Inject constructor(
initialValue = AutoBackupUiState(), initialValue = AutoBackupUiState(),
) )
/**
* Managed special-dates calendars whose deletion would not stick: while the
* feature is on, `SpecialDatesSyncEngine.reconcileCalendars` undoes it on
* the next pass. Read off each calendar's durable [CalendarSource.isManaged]
* marker rather than the stored ids, which lag a sync pass behind.
*/
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
calendars,
settingsPrefs.specialDatesEnabled,
) { sources, enabled ->
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
}
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
private val _error = MutableStateFlow(false) private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow() val error: StateFlow<Boolean> = _error.asStateFlow()
@@ -140,54 +139,21 @@ class CalendarsViewModel @Inject constructor(
} }
/** /**
* Enable or disable a calendar app-side. Disabling removes it from every * Switch a calendar on or off — the app's one visibility model, writing the
* surface but Settings → Calendars (and hides its events) without touching * system's `Calendars.VISIBLE`. A reminder that came due while the calendar
* provider data — purely a reversible Calendula-local view choice. * was off stays gone when it is switched back on: the watermark has already
* moved past it (#75).
*/ */
fun setDisabled(id: Long, disabled: Boolean) { fun setCalendarVisible(id: Long, visible: Boolean) = write {
viewModelScope.launch { repository.setCalendarsVisible(listOf(id), visible)
val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + id else current - id
if (next != current) {
prefs.setDisabledCalendarIds(next)
if (!disabled) recoverReminders(setOf(id))
}
}
} }
/** /**
* Enable or disable every calendar of one account in a single write — the * Switch every calendar of one account on or off. Each row is written on its
* "toggle all" affordance on an account header. Done as one set update so the * own, in one coroutine so the writes can't race.
* per-calendar [setDisabled] calls can't race each other.
*/ */
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) { fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
viewModelScope.launch { repository.setCalendarsVisible(ids, visible)
val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + ids else current - ids.toSet()
if (next != current) {
prefs.setDisabledCalendarIds(next)
if (!disabled) recoverReminders(current intersect ids.toSet())
}
}
}
/**
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
* still relevant (event not yet over), then drop them from the stash. Runs
* after the disabled set is written, so the notifier's own disabled gate lets
* them through. Best-effort at re-enable time: it mirrors the receiver gates
* (reminders on + postable), and there is no later re-scan, so alerts left
* unposted because those gates are closed are simply released.
*/
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
if (reEnabledIds.isEmpty()) return
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
if (recovered.isNotEmpty() &&
settingsPrefs.remindersEnabled.first() &&
notifier.canPost()
) {
recovered.forEach { notifier.post(it) }
}
} }
// --- Automatic backup (issue #8) ------------------------------------ // --- Automatic backup (issue #8) ------------------------------------

View File

@@ -0,0 +1,80 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
/**
* One account's calendars, as every surface that lists calendars by account
* shows them. An account is identified by name **and** type (#77): a Google and
* a DAVx5 account can share an address and still be two separate accounts.
*/
data class CalendarAccountGroup(
/** Stable identity: what makes two calendars belong to the same account. */
val key: AccountKey,
/** The account's own name, as shown when it is unambiguous. */
val label: String,
/** True when another group shows the same [label] under a different type. */
val ambiguous: Boolean,
val calendars: List<CalendarSource>,
) {
val accountType: String get() = key.type
}
/** The pair a group is keyed on. */
data class AccountKey(val name: String, val type: String)
/**
* Group [calendars] under their owning account, preserving the provider's order
* within each group and ordering groups by first appearance.
*
* The label falls back through name → type → the first calendar's own name, so
* a calendar with no account still lands somewhere sensible.
*/
fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
val grouped = groupBy { AccountKey(it.accountName, it.accountType) }
val labels = grouped.mapValues { (key, cals) ->
key.name.ifBlank { key.type }.ifBlank { cals.first().displayName }
}
val shared = labels.values.groupingBy { it }.eachCount()
return grouped.map { (key, cals) ->
val label = labels.getValue(key)
CalendarAccountGroup(
key = key,
label = label,
ambiguous = shared.getValue(label) > 1,
calendars = cals,
)
}
}
/**
* What to write above a group: its account name, qualified with the app the
* account comes from when another account shares the name (#77).
*/
@Composable
fun accountGroupTitle(group: CalendarAccountGroup): String =
if (!group.ambiguous) {
group.label
} else {
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
}
/**
* The human name of the app backing [accountType], falling back to the raw
* account type when no installed app resolves for it.
*/
@Composable
fun sourceAppName(accountType: String): String {
val context = LocalContext.current
return remember(accountType) {
val pm = context.packageManager
val packages = sourceAppPackages(context, accountType)
packages.firstNotNullOfOrNull { pkg ->
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
} ?: accountType
}
}

View File

@@ -14,16 +14,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.pastelize
/** /**
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted * Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
* in the calendar's (pastelised) colour. Shared by the calendar manager and the * in the calendar's colour — softened to a pastel, or raw when the softener is
* visibility filter so they read identically. * off (issue #36). Shared by the calendar manager and the visibility filter so
* they read identically.
*/ */
@Composable @Composable
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) { fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
Box( Box(
modifier = modifier modifier = modifier
.size(40.dp) .size(40.dp)
@@ -34,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
Icon( Icon(
Icons.Filled.CalendarMonth, Icons.Filled.CalendarMonth,
contentDescription = null, contentDescription = null,
tint = pastelize(color, dark), tint = eventAccent(color, dark, soften),
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
) )
} }

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.DrawerState
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
@@ -27,19 +29,23 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
/** /**
@@ -50,7 +56,8 @@ import kotlinx.datetime.LocalDate
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a * a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
* pinned Settings row. The "View" section mirrors the top-bar switcher pill — * pinned Settings row. The "View" section mirrors the top-bar switcher pill —
* tapping a view here selects it (and closes the drawer) rather than cycling. * tapping a view here selects it (and closes the drawer) rather than cycling.
* The host screen owns the drawer state. * The host screen owns the drawer state; the sheet reads it only to dismiss
* itself on back.
* *
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month * [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
* anchor); [onJumpToDate] navigates the active view to the chosen day. * anchor); [onJumpToDate] navigates the active view to the chosen day.
@@ -59,12 +66,17 @@ import kotlinx.datetime.LocalDate
fun CalendarDrawer( fun CalendarDrawer(
currentView: CalendarView, currentView: CalendarView,
currentDate: LocalDate, currentDate: LocalDate,
drawerState: DrawerState,
onSelectView: (CalendarView) -> Unit, onSelectView: (CalendarView) -> Unit,
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS, viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
) { ) {
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// Registered in the sheet so it takes precedence over the host's back handler.
BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } }
ModalDrawerSheet { ModalDrawerSheet {
// The whole sidebar scrolls as one — header, views, the calendar filter // The whole sidebar scrolls as one — header, views, the calendar filter
@@ -83,7 +95,7 @@ fun CalendarDrawer(
position = positionOf(index, viewOrder.size), position = positionOf(index, viewOrder.size),
selected = view == currentView, selected = view == currentView,
minHeight = 56.dp, minHeight = 56.dp,
leading = { Icon(view.icon, contentDescription = null) }, leading = { DrawerLeadingIcon(view.icon) },
onClick = { onSelectView(view) }, onClick = { onSelectView(view) },
) )
} }
@@ -93,7 +105,7 @@ fun CalendarDrawer(
title = stringResource(R.string.drawer_jump_to_date), title = stringResource(R.string.drawer_jump_to_date),
position = Position.Alone, position = Position.Alone,
minHeight = 56.dp, minHeight = 56.dp,
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) }, leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
onClick = { showDatePicker = true }, onClick = { showDatePicker = true },
) )
@@ -107,7 +119,7 @@ fun CalendarDrawer(
title = stringResource(R.string.month_action_settings), title = stringResource(R.string.month_action_settings),
position = Position.Alone, position = Position.Alone,
minHeight = 56.dp, minHeight = 56.dp,
leading = { Icon(Icons.Filled.Settings, contentDescription = null) }, leading = { DrawerLeadingIcon(Icons.Filled.Settings) },
onClick = onSettings, onClick = onSettings,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -126,13 +138,27 @@ fun CalendarDrawer(
} }
} }
/** Leading slot for the drawer's plain icons: the same 40.dp footprint
* [CalendarColorChip] takes, so every leading glyph shares one vertical axis. */
@Composable
private fun DrawerLeadingIcon(icon: ImageVector) {
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null)
}
}
/** Branded header: the app-icon chip beside the app name. */ /** Branded header: the app-icon chip beside the app name. */
@Composable @Composable
private fun DrawerHeader() { private fun DrawerHeader() {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp), .padding(
start = GroupedListInset,
end = GroupedListInset,
top = 24.dp,
bottom = 16.dp,
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( Box(
@@ -164,6 +190,11 @@ private fun DrawerSectionHeader(text: String) {
text = text, text = text,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp), modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 8.dp,
),
) )
} }

View File

@@ -11,9 +11,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -33,6 +34,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
/** /**
* The app's single "which calendar" selection list, shared by the event editor * The app's single "which calendar" selection list, shared by the event editor
@@ -42,18 +44,21 @@ import de.jeanlucmakiola.floret.components.Position
* account — with the calendars beneath it as a connected card, a colour chip on * account — with the calendars beneath it as a connected card, a colour chip on
* each and a check on the selected one. Emits into the caller's [ColumnScope] * each and a check on the selected one. Emits into the caller's [ColumnScope]
* (a scrolling column), so the caller owns the surrounding chrome. * (a scrolling column), so the caller owns the surrounding chrome.
*
* The list holds event *targets* only, so a switched-off, read-only or managed
* calendar is absent (#76). [onManageCalendars], when given, adds the footer row
* naming the possible reasons and opening the calendar manager.
*/ */
@Composable @Composable
fun ColumnScope.CalendarPickerGroups( fun ColumnScope.CalendarPickerGroups(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
selectedId: Long?, selectedId: Long?,
onSelect: (Long) -> Unit, onSelect: (Long) -> Unit,
onManageCalendars: (() -> Unit)? = null,
) { ) {
val local = remember(calendars) { calendars.filter { it.isLocal } } val local = remember(calendars) { calendars.filter { it.isLocal } }
val syncedGroups = remember(calendars) { val syncedGroups = remember(calendars) {
calendars.filterNot { it.isLocal } calendars.filterNot { it.isLocal }.groupByAccount()
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
.toList()
} }
if (local.isNotEmpty()) { if (local.isNotEmpty()) {
@@ -65,16 +70,33 @@ fun ColumnScope.CalendarPickerGroups(
onSelect = onSelect, onSelect = onSelect,
) )
} }
syncedGroups.forEachIndexed { index, (account, cals) -> syncedGroups.forEachIndexed { index, group ->
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp)) if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
CalendarPickerGroup( CalendarPickerGroup(
title = account, title = accountGroupTitle(group),
leading = { SourceLogo(cals.first().accountType) }, leading = { SourceLogo(group.accountType) },
calendars = cals, calendars = group.calendars,
selectedId = selectedId, selectedId = selectedId,
onSelect = onSelect, onSelect = onSelect,
) )
} }
if (onManageCalendars != null) {
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.calendar_picker_missing_title),
summary = stringResource(R.string.calendar_picker_missing_summary),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.VisibilityOff) },
trailing = {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onManageCalendars,
)
}
} }
/** One account's category header (avatar + name) atop its selectable calendars. */ /** One account's category header (avatar + name) atop its selectable calendars. */
@@ -99,13 +121,7 @@ private fun CalendarPickerGroup(
selected = isSelected, selected = isSelected,
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) { trailing = if (isSelected) {
{ { SelectedCheck() }
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else { } else {
null null
}, },
@@ -161,19 +177,21 @@ fun LeadingAvatar(icon: ImageVector) {
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */ /** The launcher icon of the app backing [accountType], preferring the human-facing app. */
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? { private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
val pm = context.packageManager val pm = context.packageManager
val candidates = buildList { for (pkg in sourceAppPackages(context, accountType)) {
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
if (bitmap != null) return bitmap.asImageBitmap()
}
return null
}
/** Apps that could stand for [accountType], best candidate first. */
internal fun sourceAppPackages(context: Context, accountType: String): List<String> = buildList {
curatedSourcePackage(accountType)?.let { add(it) } curatedSourcePackage(accountType)?.let { add(it) }
AccountManager.get(context).authenticatorTypes AccountManager.get(context).authenticatorTypes
.firstOrNull { it.type.equals(accountType, ignoreCase = true) } .firstOrNull { it.type.equals(accountType, ignoreCase = true) }
?.packageName ?.packageName
?.let { add(it) } ?.let { add(it) }
} }
for (pkg in candidates) {
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
if (bitmap != null) return bitmap.asImageBitmap()
}
return null
}
/** Preferred app for account types whose authenticator isn't the app to open. */ /** Preferred app for account types whose authenticator isn't the app to open. */
internal fun curatedSourcePackage(accountType: String): String? = when { internal fun curatedSourcePackage(accountType: String): String? = when {

View File

@@ -0,0 +1,79 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
/**
* Drag distance that turns a calendar page, shared by the month, week and day
* views so all three answer a swipe at the same point.
*
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
* changed the month, and a page that turns on an unintended gesture reads as the
* animation misfiring rather than as the gesture being over-eager.
*/
val CALENDAR_SWIPE_THRESHOLD = 24.dp
/**
* The whole-page horizontal swipe: one page per gesture, committed **the moment
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
*
* 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.
* Firing on the threshold is what makes the gesture feel like it is being
* followed. The trade is that a drag can no longer be taken back by dragging the
* other way; in practice, once you have moved 24dp deliberately you meant it, and
* the page you land on is one swipe back.
*
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
* underneath this, and a two-dimensional detector here would claim those drags
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
* detector's slop while a vertical one is consumed below, and the two coexist.
* (The month view's split style needs a vertical axis as well, so it keeps its
* own axis-locking detector rather than using this.)
*/
@Composable
fun rememberCalendarPageSwipe(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
): Modifier {
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
var accum = 0f
// One page per gesture: without this a long drag would keep re-firing
// every time the accumulator crossed the threshold again.
var fired = false
detectHorizontalDragGestures(
onDragStart = {
accum = 0f
fired = false
},
onDragEnd = {
accum = 0f
fired = false
},
onDragCancel = {
accum = 0f
fired = false
},
onHorizontalDrag = { _, drag ->
accum += drag
if (!fired) {
val commit = when {
accum < -threshold -> onSwipeNext
accum > threshold -> onSwipePrev
else -> null
}
if (commit != null) {
fired = true
commit()
}
}
},
)
}
}

View File

@@ -0,0 +1,34 @@
package de.jeanlucmakiola.calendula.ui.common
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import java.util.Locale
/**
* Formats a calendar title (top bar or widget header): [skeleton]'s fields laid
* out in [locale]'s own order, with the year shown only when [date] falls outside
* [currentYear].
*
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a
* day. A skeleton is a field list, so wanting the year is just asking for one
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月").
*
* Dropping the year in the current year is the one bit of policy here: the title
* sits directly above a grid that already says which year it is, and the year's
* *absence* is itself the signal that you're in the current one — it appears the
* moment you page out of it, which is when it starts carrying information.
*
* [forceYear] overrides that for titles whose [date] does not tell the whole
* story — a week view's title names only the month its *first* day falls in, so
* a week straddling New Year must still show the year even though [date] is in
* [currentYear].
*/
fun formatCalendarTitle(
date: java.time.LocalDate,
locale: Locale,
currentYear: Int,
skeleton: String,
forceYear: Boolean = false,
): String {
val fields = if (date.year == currentYear && !forceYear) skeleton else skeleton + "y"
return localizedDateFormatter(locale, fields).format(date)
}

View File

@@ -0,0 +1,81 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
/**
* Top-bar title that doubles as the jump-to-date entry point: the formatted
* [title] with a drop-down caret, opening the same [CalendarDatePickerDialog] the
* drawer uses and reporting the chosen day through [onJumpToDate].
*
* [currentDate] seeds the picker with whatever the bar is currently naming (the
* visible day, week start or month anchor).
*
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
* start alignment: M3 places the title flush against the navigation icon's
* trailing edge, and the title is hit-tested on top of it, so a negative offset
* would swallow taps meant for the menu button.
*/
@Composable
fun CalendarTitleButton(
title: String,
currentDate: LocalDate,
onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
var showDatePicker by rememberSaveable { mutableStateOf(false) }
Row(
modifier = modifier
.heightIn(min = 48.dp)
.clip(MaterialTheme.shapes.large)
.clickable(
onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button,
) { showDatePicker = true }
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f, fill = false),
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (showDatePicker) {
CalendarDatePickerDialog(
initial = currentDate,
onConfirm = {
showDatePicker = false
onJumpToDate(it)
},
onDismiss = { showDatePicker = false },
)
}
}

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.ContentTransform import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -23,9 +24,13 @@ import androidx.compose.ui.unit.IntOffset
*/ */
/** /**
* The M3 Expressive spatial spring used for the month/week slide: the *fast* * The M3 Expressive spatial spring used for the month/week/day slide: the
* spring-physics spec from the active motion scheme — snappy with a subtle * *default* spring-physics spec from the active motion scheme, rather than a
* springy settle, rather than a fixed easing curve. * fixed easing curve.
*
* Default rather than fast: `fastSpatialSpec` is tuned for small, incidental
* movement, and driving a whole calendar page with it made the settle read as a
* twitch instead of a glide.
* *
* Read it in a composable scope (this helper) so it can be captured by the * Read it in a composable scope (this helper) so it can be captured by the
* non-composable `AnimatedContent` transitionSpec lambda. * non-composable `AnimatedContent` transitionSpec lambda.
@@ -33,27 +38,34 @@ import androidx.compose.ui.unit.IntOffset
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> = fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec() MaterialTheme.motionScheme.defaultSpatialSpec()
/** /**
* The fast effects spec from the active motion scheme, for opacity (fade) * The effects spec from the active motion scheme, for the opacity half of the
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec] * transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
* for use in non-composable transition lambdas and as the reduced-motion fallback. * for use in non-composable transition lambdas, and reused on its own as the
* reduced-motion fallback.
*
* Opacity gets its own spec on purpose: M3 Expressive springs *position* and
* eases *opacity*, and a fade that bounced with the slide would shimmer.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> = fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.fastEffectsSpec() MaterialTheme.motionScheme.defaultEffectsSpec()
/** /**
* Horizontal slide for navigating between adjacent months/weeks/days. * Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
* outgoing page slides and fades one way while the incoming one arrives from the
* other, position on a spring and opacity on an easing curve.
* *
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump * @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction. * (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec]. * @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param fadeSpec effects spec for the reduced-motion fade, typically * @param fadeSpec effects spec for the opacity half, and for the whole
* transition under reduced motion; typically
* [rememberCalendarFadeSpec]. * [rememberCalendarFadeSpec].
* @param reduceMotion when true, swap the directional slide for a plain cross-fade. * @param reduceMotion when true, drop the movement and cross-fade alone.
*/ */
fun calendarSlideTransition( fun calendarSlideTransition(
slideDir: Int, slideDir: Int,
@@ -65,6 +77,26 @@ fun calendarSlideTransition(
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
} }
val dir = if (slideDir == 0) 1 else slideDir val dir = if (slideDir == 0) 1 else slideDir
return slideInHorizontally(spec) { w -> dir * w } return ContentTransform(
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w }) targetContentEnter =
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
initialContentExit =
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
// AnimatedContent clips to the animating container by default, which
// shears the pages against the viewport edge as they pass. There is no
// size change here to contain — both pages are the same grid.
sizeTransform = SizeTransform(clip = false),
)
} }
/**
* How far a page travels, as a fraction of the container width.
*
* A full width was the obvious reading of "paging", but the two pages are
* stacked and both opaque, so a full-width slide showed one grid racing across
* another — the movement carried the whole transition and had a long way to go.
* Under M3's shared-axis pattern the offset only has to *hint* the direction
* while the cross-fade does the swapping, so a fifth of the width is plenty and
* leaves nothing skating past.
*/
private const val SLIDE_TRAVEL_DIVISOR = 5

View File

@@ -18,15 +18,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.pastelize
/** /**
* A wrapping row of round colour swatches; the one matching [selected] is * A wrapping row of round colour swatches; the one matching [selected] is
* ringed and checked. Shared by the calendar editor and the event-colour * ringed and checked. Shared by the calendar editor and the event-colour
* picker so both pick a colour the same way. Swatches render through * picker so both pick a colour the same way. Swatches render through
* [pastelize] — the softened colour the app actually paints, not the raw hue. * [eventFill] — the colour the app actually paints (softened, or raw when the
* softener is off, issue #36), not necessarily the stored hue.
*/ */
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@@ -37,16 +36,18 @@ fun ColorSwatchRow(
dark: Boolean, dark: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val soften = LocalSoftenColors.current
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) { FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
colors.forEach { argb -> colors.forEach { argb ->
val isSelected = argb == selected val isSelected = argb == selected
val fill = eventFill(argb, dark, soften)
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier modifier = Modifier
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.size(40.dp) .size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background(pastelize(argb, dark)) .background(fill)
.then( .then(
if (isSelected) { if (isSelected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
@@ -60,7 +61,7 @@ fun ColorSwatchRow(
Icon( Icon(
Icons.Default.Check, Icons.Default.Check,
contentDescription = null, contentDescription = null,
tint = Color.Black.copy(alpha = 0.7f), tint = eventInk(fill, alpha = 0.7f),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
} }

View File

@@ -0,0 +1,54 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import de.jeanlucmakiola.calendula.domain.color.eventTone
import de.jeanlucmakiola.calendula.domain.color.inkFor
import de.jeanlucmakiola.calendula.domain.color.oklchOf
/**
* Whether calendar/event colours are harmonised — hue kept, lightness re-pinned
* per role — before display (issue #36). Provided app-wide from the setting; the
* default `true` is the app's own look. When off, the raw provider colour is
* painted verbatim, matching the sync source (DAVx5/CalDAV) and other calendar
* apps. Widgets live outside this composition and read the preference directly,
* then pass the flag through.
*/
val LocalSoftenColors = staticCompositionLocalOf { true }
/**
* Fill for a surface that carries text on it — a week/day block, a month bar, a
* widget row, a picker swatch. Pair it with [eventInk].
*/
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
Color(eventTone(rawArgb, dark, soften).container)
/**
* Colour for a mark on an ordinary app surface — a day dot, an agenda or search
* stripe, a calendar icon tint, the detail header. Same identity as [eventFill],
* pinned to contrast with the surface instead of with ink.
*/
fun eventAccent(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
Color(eventTone(rawArgb, dark, soften).accent)
/**
* Ink for text drawn on an [eventFill]. [alpha] carries the caller's emphasis.
*
* Takes the fill rather than the raw colour so it stays correct for the raw
* (un-harmonised) path too, where the provider decides the lightness.
*/
fun eventInk(fill: Color, alpha: Float = 0.8f): Color =
Color(inkFor(oklchOf(fill.toArgbInt()).lightness)).copy(alpha = alpha)
/**
* Ink alpha for a block's secondary line (the time under the title). Held above
* a fainter value because harmonised containers are deep: the small time text
* needs to stay clear of the 4.5:1 WCAG asks of body text.
*/
const val SECONDARY_INK_ALPHA = 0.8f
private fun Color.toArgbInt(): Int =
(0xFF shl 24) or
((red * 255f).toInt().coerceIn(0, 255) shl 16) or
((green * 255f).toInt().coerceIn(0, 255) shl 8) or
(blue * 255f).toInt().coerceIn(0, 255)

View File

@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Repeat
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
@@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
fun eventFormFieldLabel(field: EventFormField): Int = when (field) { fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description EventFormField.Description -> R.string.event_detail_description
EventFormField.Timezone -> R.string.event_detail_timezone
EventFormField.Reminders -> R.string.event_detail_reminders EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability EventFormField.Availability -> R.string.event_edit_availability
@@ -35,6 +37,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) { fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Location -> Icons.Default.Place EventFormField.Location -> Icons.Default.Place
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
EventFormField.Timezone -> Icons.Default.Public
EventFormField.Reminders -> Icons.Default.Notifications EventFormField.Reminders -> Icons.Default.Notifications
EventFormField.Recurrence -> Icons.Default.Repeat EventFormField.Recurrence -> Icons.Default.Repeat
EventFormField.Availability -> Icons.Default.EventAvailable EventFormField.Availability -> Icons.Default.EventAvailable

View File

@@ -1,20 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.os.ConfigurationCompat
import java.util.Locale
/**
* Current display [Locale], read observably from [LocalConfiguration] so the UI
* recomposes after a locale change (lint: NonObservableLocale). Used for
* weekday/month name formatting.
*/
@Composable
fun currentLocale(): Locale {
val configuration = LocalConfiguration.current
return remember(configuration) {
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
}
}

View File

@@ -1,26 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -29,23 +16,33 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.CustomAmountEditor
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderUnit import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/** /**
* Reminder-default picker, full-screen and **multi-select**: each [presets] * Reminder-default picker, full-screen and **multi-select**: each [presets]
@@ -61,6 +58,10 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
* row expands an inline number field plus a unit selector to add an arbitrary * row expands an inline number field plus a unit selector to add an arbitrary
* lead time to the set. Changes apply live via [onSelect]; the user leaves via * lead time to the set. Changes apply live via [onSelect]; the user leaves via
* back. * back.
*
* [leadTimeSummary] adds a second line to each lead-time row, for the all-day
* pickers: the hour comes from the separate "show all-day reminders at" setting,
* so the lead time alone doesn't say when anything happens.
*/ */
@Composable @Composable
fun ReminderDefaultPicker( fun ReminderDefaultPicker(
@@ -70,6 +71,7 @@ fun ReminderDefaultPicker(
allowInherit: Boolean, allowInherit: Boolean,
onSelect: (ReminderOverride) -> Unit, onSelect: (ReminderOverride) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
leadTimeSummary: (@Composable (Int) -> String?)? = null,
) { ) {
// Optimistic local state: once the user edits, the chosen override is // Optimistic local state: once the user edits, the chosen override is
// authoritative while the picker is open, so quick successive toggles compose // authoritative while the picker is open, so quick successive toggles compose
@@ -145,6 +147,7 @@ fun ReminderDefaultPicker(
val checked = minute in selectedMinutes val checked = minute in selectedMinutes
GroupedRow( GroupedRow(
title = reminderLeadTimeLabel(minute), title = reminderLeadTimeLabel(minute),
summary = leadTimeSummary?.invoke(minute),
position = positionOf(index, rowCount), position = positionOf(index, rowCount),
selected = checked, selected = checked,
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) }, trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
@@ -195,89 +198,49 @@ private fun CustomReminderEditor(
onConfirm: (Int) -> Unit, onConfirm: (Int) -> Unit,
) { ) {
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 } val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
Surface( CustomAmountEditor(
color = MaterialTheme.colorScheme.surfaceContainerHigh, amountText = amountText,
// A Position.Bottom shape: tight top corners meeting the row, full bottom. onAmountChange = onAmountChange,
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp), unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
modifier = Modifier selectedUnit = unit.ordinal,
.fillMaxWidth() onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
.padding(horizontal = 16.dp), preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Unit toggle first so it stays visible above the keyboard once the
// amount field (the bottom row) is focused and scrolled into view.
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ReminderUnit.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = unit == entry,
onClick = { onUnitChange(entry) },
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
label = { Text(stringResource(reminderUnitLabel(entry))) },
)
}
}
// Amount, a live preview of the lead time it resolves to, and Set —
// all on one row, sitting just above the keyboard.
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(
value = amountText,
onValueChange = onAmountChange,
placeholder = "10",
)
Spacer(Modifier.width(16.dp))
Text(
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount), ?: stringResource(R.string.reminder_custom_amount),
style = MaterialTheme.typography.bodyLarge, setLabel = stringResource(R.string.reminder_custom_set),
color = MaterialTheme.colorScheme.onSurfaceVariant, confirmEnabled = amount != null,
modifier = Modifier.weight(1f), onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
) )
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
enabled = amount != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
}
}
}
} }
/** A short explanatory paragraph shown under a picker's title, above the rows. */ /** A short explanatory paragraph shown under a picker's title, above the rows,
* on the same left margin as the rows themselves. */
@Composable @Composable
private fun PickerDescription(text: String) { internal fun PickerDescription(text: String) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
) )
} }
@Composable
private fun SelectedCheck() {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
/** /**
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned * Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
* options (today / this week / this month) and the rolling windows (next 7 / 30 * options (today / this week / this month) and the rolling windows (next 7 / 30
* days), with a "Custom" row that expands an inline day-count editor (1365). * days), with a "Custom" row that expands an inline day-count editor (1365).
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern. * Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
*
* Every option carries the dates it resolves to today as its summary, computed
* through the same [dayCount] the agenda windows by (hence [weekStart]) — only
* the concrete span tells "This week" and "Next 7 days" apart.
*/ */
@Composable @Composable
fun AgendaRangePicker( fun AgendaRangePicker(
title: String, title: String,
description: String, description: String,
selected: AgendaRange, selected: AgendaRange,
weekStart: DayOfWeek,
onSelect: (AgendaRange) -> Unit, onSelect: (AgendaRange) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
@@ -291,10 +254,21 @@ fun AgendaRangePicker(
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "") mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
} }
val locale = currentLocale()
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
// The agenda's own end-day arithmetic (anchor + dayCount - 1), so the dates
// match what it will show.
val windowSummary: (AgendaRange) -> String = { range ->
val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
agendaRangeWindowSummary(range, today, end, locale, monthAsSpan = true)
}
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position -> val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
val isSelected = option == selected val isSelected = option == selected
GroupedRow( GroupedRow(
title = agendaRangeLabel(option), title = agendaRangeLabel(option),
summary = windowSummary(option),
position = position, position = position,
selected = isSelected, selected = isSelected,
trailing = if (isSelected) { trailing = if (isSelected) {
@@ -329,6 +303,9 @@ fun AgendaRangePicker(
} else { } else {
stringResource(R.string.agenda_range_custom) stringResource(R.string.agenda_range_custom)
}, },
// An unset custom window has no day count yet, so the row stays
// single-line until it does.
summary = if (customSelected) windowSummary(selected) else null,
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount), position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
selected = customSelected, selected = customSelected,
trailing = if (customSelected) { trailing = if (customSelected) {
@@ -364,39 +341,173 @@ private fun CustomDaysEditor(
) { ) {
val days = amountText.toIntOrNull() val days = amountText.toIntOrNull()
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS } ?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
Surface( CustomAmountEditor(
color = MaterialTheme.colorScheme.surfaceContainerHigh, amountText = amountText,
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp), onAmountChange = onAmountChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DialogAmountField(
value = amountText,
onValueChange = onAmountChange,
placeholder = "30", placeholder = "30",
) preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
Spacer(Modifier.width(16.dp))
Text(
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
?: stringResource(R.string.agenda_range_custom_hint), ?: stringResource(R.string.agenda_range_custom_hint),
style = MaterialTheme.typography.bodyLarge, setLabel = stringResource(R.string.reminder_custom_set),
color = MaterialTheme.colorScheme.onSurfaceVariant, confirmEnabled = days != null,
modifier = Modifier.weight(1f), onConfirm = { days?.let(onConfirm) },
) )
Spacer(Modifier.width(16.dp)) }
FilledTonalButton(
onClick = { days?.let(onConfirm) }, /**
enabled = days != null, * Duration picker, full-screen and **single-select**: the [presets] (whole-minute
* lengths) each sit as a checkmark row, with a "Custom" row that expands an
* inline amount field plus a Minutes/Hours unit toggle to enter an arbitrary
* one. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking a preset or
* confirming a custom value applies via [onSelect] and closes. [label] renders a
* length in minutes ("10 minutes", "1 hour") and is reused for both the rows and
* the custom preview.
*
* [inheritLabel] adds an exclusive "use the default" row on top, for the
* per-calendar pickers; picking it reports null. [selected] is null exactly when
* that row is the current choice. [description] explains the setting above the
* rows when it needs it. [maxMinutes] caps what the custom editor will confirm,
* for settings that clamp their stored value — offering a length the store would
* silently shorten is worse than refusing it.
*/
@Composable
fun DurationPicker(
title: String,
presets: List<Int>,
selected: Int?,
label: @Composable (Int) -> String,
onSelect: (Int?) -> Unit,
onDismiss: () -> Unit,
description: String? = null,
inheritLabel: String? = null,
maxMinutes: Int = Int.MAX_VALUE,
) { ) {
Text(stringResource(R.string.reminder_custom_set)) val inherits = selected == null
// The current choice when it isn't one of the presets — the Custom row then
// names it and the editor opens pre-filled with it.
val custom = selected?.takeIf { it !in presets }
val rowCount = presets.size + 1 // + the custom row
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable {
mutableStateOf(custom?.let { durationCustomAmount(it).toString() }.orEmpty())
}
var unit by rememberSaveable {
mutableStateOf(custom?.let { durationCustomUnit(it) } ?: ReminderUnit.Minutes)
}
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
if (description != null) PickerDescription(description)
if (inheritLabel != null) {
GroupedRow(
title = inheritLabel,
position = Position.Alone,
selected = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(null)
onDismiss()
},
)
Spacer(Modifier.height(24.dp))
}
presets.forEachIndexed { index, minute ->
val isSelected = minute == selected
GroupedRow(
title = label(minute),
position = positionOf(index, rowCount),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(minute)
onDismiss()
},
)
}
// The Custom row connects downward into the editor card when expanded, so
// the two read as one grouped container (the shared custom-expand pattern).
GroupedRow(
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
selected = custom != null,
trailing = if (custom != null) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) {
CustomDurationEditor(
amountText = amountText,
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
label = label,
maxMinutes = maxMinutes,
onConfirm = { minutes ->
onSelect(minutes)
onDismiss()
},
)
} }
} }
} }
/** Whole hours if the length divides evenly, else minutes. */
private fun durationCustomUnit(minutes: Int): ReminderUnit =
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
private fun durationCustomAmount(minutes: Int): Int =
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
/**
* The expanded "Custom" duration editor: a tonal card connected to the Custom row
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
* the length it resolves to, and a tonal confirm enabled only for a valid 1999
* amount that stays within [maxMinutes]. Over the cap, the preview names the
* limit instead of a length the setting could not keep. [onConfirm] receives the
* final length in minutes.
*/
@Composable
private fun CustomDurationEditor(
amountText: String,
onAmountChange: (String) -> Unit,
unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit,
label: @Composable (Int) -> String,
maxMinutes: Int,
onConfirm: (Int) -> Unit,
) {
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
val entered = amountText.toIntOrNull()?.takeIf { it in 1..999 }?.times(unit.minutesFactor)
val amount = entered?.takeIf { it <= maxMinutes }
CustomAmountEditor(
amountText = amountText,
onAmountChange = onAmountChange,
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
onUnitChange = { onUnitChange(units[it]) },
preview = when {
amount != null -> label(amount)
entered != null -> stringResource(R.string.duration_custom_max, label(maxMinutes))
else -> stringResource(R.string.reminder_custom_amount)
},
setLabel = stringResource(R.string.reminder_custom_set),
confirmEnabled = amount != null,
onConfirm = { amount?.let(onConfirm) },
)
} }
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */ /** Human label for an [AgendaRange] (used by the picker rows and settings summary). */

View File

@@ -8,7 +8,16 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.toJavaLocalDate
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
@@ -94,6 +103,33 @@ fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
} }
} }
/**
* The rule's first few dates as one line — "Next: 30 Jul, 6 Aug, 13 Aug" — for
* the recurrence picker, where [recurrenceText]'s phrase says what the rule is
* and this says what it does. Expands via [upcomingOccurrences] and inherits its
* limits. A rule that yields nothing says so rather than showing an empty list.
*/
@Composable
fun nextOccurrencesText(
rule: SimpleRecurrence,
start: kotlinx.datetime.LocalDate,
locale: Locale,
): String {
val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT)
if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none)
// Years only once they carry information (see [spansMultipleYears]).
val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM"
val formatter = localizedDateFormatter(locale, pattern)
val formatted = dates.map { formatter.format(it.toJavaLocalDate()) }
return stringResource(
R.string.event_edit_recurrence_next,
ListFormatter.getInstance(locale).format(formatted),
)
}
/** Enough dates to show a rhythm (and a skipped month), few enough for one line. */
private const val NEXT_OCCURRENCE_COUNT = 3
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */ /** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
private fun rruleDayName(token: String, locale: Locale): String? { private fun rruleDayName(token: String, locale: Locale): String? {
val dow = when (token.takeLast(2).uppercase()) { val dow = when (token.takeLast(2).uppercase()) {
@@ -111,16 +147,36 @@ private fun rruleDayName(token: String, locale: Locale): String? {
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */ /** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
private fun parseUntilDate(raw: String, locale: Locale): String? { private fun parseUntilDate(raw: String, locale: Locale): String? {
val digits = raw.takeWhile { it.isDigit() } val date = untilLocalDate(raw) ?: return null
if (digits.length < 8) return null return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
return try { }
val date = java.time.LocalDate.of(
private val UNTIL_UTC_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT)
/**
* The calendar day an RRULE UNTIL value denotes, *in the device's zone*.
*
* The UTC form ("20261231T225959Z") must be converted back before its date is
* read: `SimpleRecurrence.toRRule` writes the end of the chosen **local** day
* expressed in UTC, so for zones behind UTC that instant already falls on the
* following UTC date. Taking the leading digits straight off would then show
* the day after the one the user picked. Date-only and floating forms are
* already local and pass through unchanged.
*/
internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()): LocalDate? {
val value = raw.trim()
return runCatching {
LocalDateTime.parse(value, UNTIL_UTC_FORMAT)
.atOffset(ZoneOffset.UTC)
.atZoneSameInstant(zone)
.toLocalDate()
}.recoverCatching {
val digits = value.takeWhile { it.isDigit() }
LocalDate.of(
digits.substring(0, 4).toInt(), digits.substring(0, 4).toInt(),
digits.substring(4, 6).toInt(), digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(), digits.substring(6, 8).toInt(),
) )
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date) }.getOrNull()
} catch (e: Exception) {
null
}
} }

View File

@@ -37,3 +37,21 @@ fun reminderLeadTimeLabel(minutes: Int): String = when {
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60) pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes) else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
} }
/**
* Humanise a plain duration — no "before": "45 minutes", "8 hours", or both
* parts for a mixed length ("1 hour 30 minutes"). Shared by the snooze delay and
* the default event duration (#54).
*/
@Composable
fun durationLabel(minutes: Int): String {
val hours = minutes / 60
val rest = minutes % 60
val hoursLabel = pluralStringResource(R.plurals.duration_hours, hours, hours)
val minutesLabel = pluralStringResource(R.plurals.duration_minutes, rest, rest)
return when {
hours == 0 -> minutesLabel
rest == 0 -> hoursLabel
else -> stringResource(R.string.duration_hours_minutes, hoursLabel, minutesLabel)
}
}

View File

@@ -0,0 +1,273 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.filterTimeZones
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Full-screen zone picker: a query field over every zone the JVM knows, with the
* device zone and recently-picked ones pinned on top so the common case needs no
* typing. [selected] is the pinned zone id, or null when the event follows the
* device — picking the device row reports null back, which is what keeps an
* unpinned event unpinned rather than freezing it to today's zone.
*
* Unlike the kit's [de.jeanlucmakiola.floret.components.OptionPicker], this
* drives its own [LazyColumn] (hence `scrollable = false`): ~600 options is far
* past what an eagerly composed column can carry.
*/
@Composable
fun TimeZonePickerDialog(
selected: String?,
deviceZoneId: String,
recents: List<String>,
onSelect: (String?) -> Unit,
onDismiss: () -> Unit,
) {
var query by rememberSaveable { mutableStateOf("") }
val locale = currentLocale()
// ~600 zones, each resolving a localized name and up to two ICU short names:
// far too much to run inside composition, so build it on a worker and let the
// list fill in a frame later. Filtering the built catalogue is cheap (its
// search keys are pre-normalized), so that stays here.
val allZones by produceState(initialValue = emptyList<TimeZoneOption>(), locale) {
value = withContext(Dispatchers.Default) {
timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion)
}
}
val matches = remember(allZones, query) { filterTimeZones(allZones, query) }
val recentZones = remember(allZones, recents) {
recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } }
}
// Resolved on its own rather than looked up in the catalogue: it's one zone,
// so it costs nothing, and the device row can then render complete on the
// first frame instead of showing a bare id until the catalogue lands.
val deviceSummary = remember(deviceZoneId, locale) {
timeZoneOptionOf(deviceZoneId, locale, regionOf = ::icuTimeZoneRegion)
?.let { zoneDescriptor(it) }
?: deviceZoneId
}
val searching = query.isNotBlank()
fun choose(zoneId: String?) {
onSelect(zoneId)
onDismiss()
}
FullScreenPicker(
title = stringResource(R.string.event_detail_timezone),
onDismiss = onDismiss,
scrollable = false,
) {
SearchField(
query = query,
onQueryChange = { query = it },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
if (!searching) {
// The device row is the "unpinned" choice, so it reads as a mode
// rather than as a zone among zones — it stays out of the big
// list and never carries an offset.
item(key = "device") {
GroupedRow(
title = stringResource(R.string.event_edit_timezone_device),
summary = deviceSummary,
position = Position.Alone,
selected = selected == null,
leading = { Icon(Icons.Default.Public, contentDescription = null) },
trailing = if (selected == null) {
{ SelectedCheck() }
} else {
null
},
onClick = { choose(null) },
)
}
if (recentZones.isNotEmpty()) {
item(key = "recent_header") {
SectionHeader(stringResource(R.string.event_edit_timezone_recent))
}
itemsIndexed(
items = recentZones,
key = { _, zone -> "recent_${zone.id}" },
) { index, zone ->
ZoneRow(
zone = zone,
position = positionOf(index, recentZones.size),
selected = zone.id == selected,
onClick = { choose(zone.id) },
)
}
}
item(key = "all_header") {
SectionHeader(stringResource(R.string.event_edit_timezone_all))
}
}
// allZones.isNotEmpty() gates this: until the catalogue lands there is
// simply nothing to match yet, and claiming "no time zone matches"
// for that frame would be wrong.
if (searching && matches.isEmpty() && allZones.isNotEmpty()) {
item(key = "empty") {
Text(
text = stringResource(R.string.event_edit_timezone_none, query),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 48.dp),
)
}
}
itemsIndexed(
items = matches,
key = { _, zone -> "zone_${zone.id}" },
) { index, zone ->
ZoneRow(
zone = zone,
position = positionOf(index, matches.size),
selected = zone.id == selected,
onClick = { choose(zone.id) },
)
}
}
}
}
/**
* The query box: the family's borderless [InlineTextField] over a tonal surface,
* so it reads like the rest of the app's inputs rather than a boxed Material
* field. The clear button rides inside the surface (unlike the search screen,
* which has a top bar to put it in) because the picker's bar is the title.
*/
@Composable
private fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val keyboard = LocalSoftwareKeyboardController.current
// surfaceContainerHighest — the picker sits on surfaceContainerHigh, so
// anything lower vanishes into it.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(28.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp),
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
InlineTextField(
value = query,
onValueChange = onQueryChange,
placeholder = stringResource(R.string.event_edit_timezone_search),
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() },
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp, vertical = 14.dp),
)
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.search_clear),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/** A small primary-coloured group label, matching the settings screens. */
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}
@Composable
private fun ZoneRow(
zone: TimeZoneOption,
position: Position,
selected: Boolean,
onClick: () -> Unit,
) {
GroupedRow(
title = zone.label,
summary = zoneDescriptor(zone),
position = position,
selected = selected,
trailing = if (selected) {
{ SelectedCheck() }
} else {
null
},
onClick = onClick,
)
}

View File

@@ -0,0 +1,16 @@
package de.jeanlucmakiola.calendula.ui.common
import android.icu.util.TimeZone as IcuTimeZone
/**
* The ISO 3166 region an IANA zone belongs to ("America/New_York" -> "US"), or
* null when ICU has none or maps it to the multi-country "001" ("Etc/UTC").
*
* This is the `android.icu`-backed bridge the pure-JVM
* [de.jeanlucmakiola.calendula.domain.timeZoneOptions] takes as `regionOf`, so
* it can ask ICU for a zone's abbreviation in that zone's own region — the only
* region for which ICU will surface it (see `resolveAbbreviation`). Lives here,
* not in `domain/`, precisely because it needs an Android import.
*/
fun icuTimeZoneRegion(id: String): String? =
runCatching { IcuTimeZone.getRegion(id) }.getOrNull()?.takeIf { it.length == 2 }

View File

@@ -0,0 +1,191 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* How tall one hour is drawn in the week and day timelines (#56).
*
* One shared setting for both views: they are the same grid at different widths,
* and a scale that only applied to one of them would read as a bug.
*
* [FitDay] is the answer to the actual complaint behind the issue — on a tall
* phone the default scale shows about half a day, so a whole week can hide
* appointments below the fold. It derives the hour height from the timeline's
* own viewport instead of a fixed value.
*
* [Custom] is what a pinch on the timeline leaves behind. The presets are the
* settings-screen vocabulary; a pinch is a direct manipulation and should be
* able to land anywhere between them, so it gets its own case rather than
* snapping to the nearest named step.
*/
sealed interface TimelineScale {
/** Whole day in one screen: the hour height follows the viewport. */
data object FitDay : TimelineScale
/** Denser than the default, still a fixed height. */
data object Compact : TimelineScale
/** The historical 56dp scale. */
data object Regular : TimelineScale
/** Roomier blocks, more scrolling. */
data object Comfortable : TimelineScale
/** A height the user pinched to. Build it through [custom], which clamps. */
data class Custom(val hourHeight: Dp) : TimelineScale
companion object {
/** The named steps the settings picker offers, coarse to roomy. */
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
/**
* A pinched hour height. The floor a pinch actually stops at depends on
* the viewport and is applied when the height is resolved (see
* [hourHeight]); this only holds a stored value to something sane.
*/
fun custom(hourHeight: Dp): Custom =
Custom(hourHeight.coerceIn(MIN_STORED_HOUR_HEIGHT, MAX_PINCH_HOUR_HEIGHT))
}
}
/**
* Hour height for this scale. [viewportHeight] is the visible height of the
* scrolling timeline and is only consulted by [TimelineScale.FitDay].
*
* The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop
* being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its
* blocks absurdly. On a screen too short for the whole day the clamp wins and
* the timeline still scrolls a little — honest, rather than unreadable.
*
* A pinched height is held to [fillHourHeight] here and not only in the gesture,
* because the viewport can change under a height that was already stored: pinch
* all the way out in landscape and the same value would leave portrait with dead
* space under midnight.
*/
fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
TimelineScale.FitDay -> (viewportHeight / 24f).coerceIn(FIT_DAY_MIN, FIT_DAY_MAX)
TimelineScale.Compact -> 40.dp
TimelineScale.Regular -> 56.dp
TimelineScale.Comfortable -> 80.dp
is TimelineScale.Custom -> this.hourHeight
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
.coerceAtLeast(fillHourHeight(viewportHeight))
}
/**
* Shortest an event block may render, as a fraction of an hour. Blocks keep a
* floor so a 15-minute event stays tappable, but the floor scales with the hour
* height — a fixed 24dp would swallow half an hour once zoomed out and make
* short events overlap their neighbours.
*/
const val MIN_EVENT_FRACTION = 26f / 60f
/**
* Narrowest an event block may be and still wrap its title over several lines.
*
* Wrapping is driven by the block's height, so a tall block on a lane-split
* column would otherwise stack two or three characters per line — "Da/ily",
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
* clears this on any phone; a split one never does, while the day view's much
* wider columns keep wrapping even several lanes deep.
*/
val MIN_TITLE_WRAP_WIDTH = 36.dp
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MIN = 24.dp
/** Largest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MAX = 96.dp
/**
* The hour height at which all 24 hours exactly fill [viewportHeight] — how far
* out a pinch can zoom.
*
* Below it there is no more day left to uncover, so the only thing shrinking
* further buys is empty space under midnight. Zooming out means "show me more of
* the day", and once the whole day is on screen that request is answered.
*/
fun fillHourHeight(viewportHeight: Dp): Dp = viewportHeight / 24f
/**
* Ceiling for a pinch, deliberately far above [TimelineScale.Comfortable]: a
* pinch is a deliberate act, so someone zooming in on a single busy afternoon
* should be allowed to go further than any preset offers.
*/
val MAX_PINCH_HOUR_HEIGHT = 240.dp
/**
* Floor for a *stored* pinched height, which only has to stay sane enough to be
* re-clamped against whatever viewport it is later shown in. The floor a pinch
* stops at is [fillHourHeight].
*/
private val MIN_STORED_HOUR_HEIGHT = 1.dp
@get:StringRes
val TimelineScale.labelRes: Int
get() = when (this) {
TimelineScale.FitDay -> R.string.timeline_scale_fit_day
TimelineScale.Compact -> R.string.timeline_scale_compact
TimelineScale.Regular -> R.string.timeline_scale_regular
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
is TimelineScale.Custom -> R.string.timeline_scale_custom
}
@get:StringRes
val TimelineScale.descriptionRes: Int
get() = when (this) {
TimelineScale.FitDay -> R.string.timeline_scale_fit_day_summary
TimelineScale.Compact -> R.string.timeline_scale_compact_summary
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
}
/** Marks a stored custom height; the rest of the value is its dp. */
private const val CUSTOM_PREFIX = "custom:"
/**
* Stored names for the presets. Spelled out rather than taken from `toString()`
* so R8 can't rename them out from under an existing install — and they match
* the enum names this used to be, so a value written before the pinch existed
* still reads back as the same preset.
*/
private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
TimelineScale.FitDay to "FitDay",
TimelineScale.Compact to "Compact",
TimelineScale.Regular to "Regular",
TimelineScale.Comfortable to "Comfortable",
)
/**
* Serialise for the `timeline_scale` preference; see [parseTimelineScale].
*
* Spelled out rather than an `else` into [PRESET_NAMES], so a preset added later
* without a stored name is a compile error here instead of a crash the first
* time someone picks it.
*/
fun TimelineScale.storageValue(): String = when (this) {
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
TimelineScale.FitDay,
TimelineScale.Compact,
TimelineScale.Regular,
TimelineScale.Comfortable,
-> PRESET_NAMES.getValue(this)
}
/**
* Parse a stored scale. Anything unrecognised — a null key, a name from a
* future release, a truncated custom height — falls back to
* [TimelineScale.Regular] rather than throwing, like the other enum prefs.
*/
fun parseTimelineScale(stored: String?): TimelineScale = when {
stored == null -> TimelineScale.Regular
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
?.let { TimelineScale.custom(it.dp) }
?: TimelineScale.Regular
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
}

View File

@@ -0,0 +1,230 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
/**
* The scale the timelines draw at right now: the stored preference, or whatever
* a pinch is currently doing to it (#56).
*
* A pinch changes the scale on every pointer frame, which the preference alone
* cannot carry — a DataStore round trip per frame would lag the gesture badly
* behind the fingers. So the live value lives here in composition state and only
* the settled result is written back, once, when the fingers lift.
*/
@Stable
class TimelineZoom(
initial: TimelineScale,
private val persist: (TimelineScale) -> Unit,
) {
/** What the week and day timelines should draw at. */
var scale: TimelineScale by mutableStateOf(initial)
private set
private var pinching = false
/**
* Take a value that came from the preference. Ignored mid-pinch: the stored
* value is a frame or two behind the fingers there, and letting it land would
* snap the timeline back while the user is still pinching.
*/
fun adopt(stored: TimelineScale) {
if (!pinching) scale = stored
}
fun beginPinch() {
pinching = true
}
fun pinchTo(hourHeight: Dp) {
scale = TimelineScale.custom(hourHeight)
}
fun endPinch() {
pinching = false
persist(scale)
}
}
/**
* The zoom the timelines read. Falls back to a detached instance — one shared
* lazy value, not one per read, or each read would hand out a fresh state — so a
* preview or test can render a timeline without the activity's provider.
*/
private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {} }
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
/**
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
* back through [onPersist].
*/
@Composable
fun rememberTimelineZoom(
stored: TimelineScale,
onPersist: (TimelineScale) -> Unit,
): TimelineZoom {
val persist by rememberUpdatedState(onPersist)
val zoom = remember { TimelineZoom(stored) { persist(it) } }
// Picking a preset in Settings has to reach the timelines, and so does the
// stored value arriving after the first frame.
LaunchedEffect(stored) { zoom.adopt(stored) }
return zoom
}
/**
* Two-finger pinch that rescales a 24-hour timeline (#56) — the gesture the
* issue actually asked for; the Settings presets stay as the accessible route to
* the same thing.
*
* Three gestures share this area, so the pinch is deliberately the fussiest
* about claiming it: nothing happens until a *second* finger is down and the
* spread has grown past [PINCH_SLOP], which leaves one-finger vertical scrolling
* and the horizontal week swipe untouched, and lets a two-finger drag still
* scroll. It watches on [PointerEventPass.Initial] because the scroll it has to
* outrank is a descendant — on the main pass the scroll would have consumed the
* drag before this ever saw it.
*
* [hourHeight] is the *resolved* height, so a pinch that starts from
* `FitDay` picks up where the viewport left it rather than jumping.
* [viewportHeight] sets how far out it can go — see [fillHourHeight].
*/
@Composable
fun rememberTimelinePinchZoom(
scrollState: ScrollState,
viewportHeight: Dp,
hourHeight: Dp,
zoom: TimelineZoom,
): Modifier {
// The gesture loop outlives any single composition, so it reads these
// through state handles rather than capturing what they were when it started.
val currentHourHeight = rememberUpdatedState(hourHeight)
val currentViewport = rememberUpdatedState(viewportHeight)
return Modifier.pointerInput(scrollState, zoom) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
var claimed = false
var slop = 1f
// Scroll the layout could not give us yet, carried to the next frame
// (see anchoredScroll).
var pending = 0f
try {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
if (event.changes.none { it.pressed }) break
if (event.changes.count { it.pressed } >= 2) {
val step = event.calculateZoom()
if (!claimed) {
slop *= step
if (abs(slop - 1f) >= PINCH_SLOP) {
claimed = true
zoom.beginPinch()
}
}
if (claimed) {
val old = currentHourHeight.value.toPx()
val new = pinchedHourHeightPx(
target = old * step,
fillPx = fillHourHeight(currentViewport.value).toPx(),
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
)
// Half a pixel, not equality: the height round-trips
// through dp and back, and an exact test would read
// the float noise that comes back as a scale change
// and feed the scroll a delta on every frame of a
// held pinch.
if (abs(new - old) >= 0.5f) {
val centroidY = event.calculateCentroid(useCurrent = true).y
pending += anchoredScroll(scrollState.value, centroidY, old, new) -
scrollState.value
zoom.pinchTo(new.toDp())
}
pending -= scrollState.dispatchRawDelta(pending)
}
}
// Hold the gesture to the end once it has become a pinch:
// letting go the moment a finger lifts would turn the tail of
// a zoom into a scroll, and the taps underneath into a new event.
if (claimed) event.changes.forEach { if (it.pressed) it.consume() }
}
} finally {
// In a finally because the gesture can also end by having its
// pointer node disposed mid-pinch — the timeline swapping out
// under the fingers. The zoom outlives that node, and a pinch
// left open would make it ignore every later Settings change.
if (claimed) zoom.endPinch()
}
}
}
}
/**
* Where a pinch aiming at [target] px per hour actually lands: clamped between
* "the whole day fills the screen" ([fillPx]) and [maxPx], then rounded to a
* whole pixel.
*
* The rounding is not cosmetic. The hour gutter is 24 stacked boxes one hour
* tall, and each rounds its own height to whole pixels, while the hour lines and
* event blocks are drawn at the fractional height — so a height of 56.4px lays
* the labels out at 56px and leaves the 23:00 label ~9px above the line it
* names, jumping the whole column as a pinch drifts across each half pixel.
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
*
* The bounds themselves are pulled onto that grid too, each in the direction
* that keeps its own promise — up for the fill floor, so no dead space opens
* under midnight, down for the ceiling. A fractional bound would be a height the
* pinch can be held against but never actually land on, and the difference feeds
* the focal anchor a scroll correction on every frame the fingers sit still.
*/
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
// Filling the viewport wins over the ceiling: on a screen tall enough for
// the two to disagree, dead space is the worse of the two failures.
target.roundToInt().toFloat()
.coerceAtMost(floor(maxPx))
.coerceAtLeast(ceil(fillPx))
/**
* The scroll offset that keeps the moment under [centroidY] under it after the
* hour height changes from [oldHourPx] to [newHourPx].
*
* Anchoring on the fingers is what makes a zoom feel like the day is being
* stretched rather than replaced: without it, zooming in on the evening walks
* the evening off the bottom of the screen.
*
* The caller may not get all of this at once — the content only grows on the
* next layout pass, so scrolling further down than the *current* content allows
* is refused — which is why the shortfall is carried over and re-offered.
*/
internal fun anchoredScroll(
scroll: Int,
centroidY: Float,
oldHourPx: Float,
newHourPx: Float,
): Float = ((scroll + centroidY) / oldHourPx) * newHourPx - centroidY
/**
* How far the fingers must spread or close before the pinch takes over. Small
* enough to feel immediate, wide enough that the two-finger scroll a user meant
* as a scroll stays one.
*/
private const val PINCH_SLOP = 0.08f

View File

@@ -0,0 +1,26 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
/**
* The top-bar "jump to today" icon button, shared by every calendar view's app
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
* today control into the toolbar; renders nothing when [show] is false, so it
* drops straight into an app bar's `actions` slot without a wrapping condition.
*/
@Composable
fun TodayAction(show: Boolean, onToday: () -> Unit) {
if (!show) return
IconButton(onClick = onToday) {
Icon(
imageVector = Icons.Default.Today,
contentDescription = stringResource(R.string.today_jump_action),
)
}
}

View File

@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -68,8 +66,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -77,13 +78,21 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
@@ -91,13 +100,17 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import java.time.format.TextStyle as JavaTextStyle import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.util.Locale import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp private val GUTTER_WIDTH = 48.dp
private val MIN_EVENT_HEIGHT = 24.dp /** Start inset for the gutter's hour labels so they centre on the top bar's
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
* 4dp inset + 24dp half icon button), matching the week view. */
private val GUTTER_CONTENT_START_INSET = 8.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp
@@ -119,6 +132,7 @@ fun DayScreen(
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS, quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS, drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialDateIso: String? = null, initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(), viewModel: DayViewModel = hiltViewModel(),
@@ -151,6 +165,13 @@ fun DayScreen(
else -> true else -> true
} }
// Drives whether the title carries the year. Falls back to the clock only
// while the first load is in flight, when there is no state to read today from.
val currentYear = when (val s = state) {
is DayUiState.Success -> s.today.year
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
}
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump. // Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) } var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() } val goNext = { slideDir = 1; viewModel.goToNext() }
@@ -178,6 +199,7 @@ fun DayScreen(
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = date, currentDate = date,
drawerState = drawerState,
viewOrder = drawerViewOrder, viewOrder = drawerViewOrder,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
@@ -199,16 +221,20 @@ fun DayScreen(
topBar = { topBar = {
DayTopBar( DayTopBar(
date = date, date = date,
currentYear = currentYear,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
CalendarFabColumn( CalendarFabColumn(
todayVisible = !isOnToday, todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.day_today_action), todayText = stringResource(R.string.day_today_action),
onToday = jumpToToday, onToday = jumpToToday,
onCreate = { onCreateEvent(date, null) }, onCreate = { onCreateEvent(date, null) },
@@ -244,9 +270,6 @@ private fun DayContent(
onCreateAt: (LocalDate, Int) -> Unit, onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val density = LocalDensity.current
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
@@ -257,11 +280,9 @@ private fun DayContent(
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 } snapshotFlow { scrollState.maxValue }.first { it > 0 }
val maxV = scrollState.maxValue // Half the scroll range *is* noon: the content spans a full 24 hours, so
val target = with(density) { // centring the range centres midday at whatever hour height is in force.
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt() scrollState.scrollTo(scrollState.maxValue / 2)
}.coerceIn(0, maxV)
scrollState.scrollTo(target)
} }
// Single, hoisted all-day strip height — shared by the outgoing and incoming // Single, hoisted all-day strip height — shared by the outgoing and incoming
@@ -275,20 +296,7 @@ private fun DayContent(
// Whole-page horizontal swipe, one level above the timeline's vertical // Whole-page horizontal swipe, one level above the timeline's vertical
// scroll: a horizontal drag crosses this detector's slop, while a vertical // scroll: a horizontal drag crosses this detector's slop, while a vertical
// drag is consumed by the inner scroll first — the two gestures coexist. // drag is consumed by the inner scroll first — the two gestures coexist.
val swipeModifier = Modifier.pointerInput(Unit) { val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
AnimatedContent( AnimatedContent(
targetState = state, targetState = state,
@@ -354,17 +362,23 @@ private fun DaySuccess(
@Composable @Composable
private fun DayTopBar( private fun DayTopBar(
date: LocalDate, date: LocalDate,
currentYear: Int,
selectedView: CalendarView, selectedView: CalendarView,
onCycleView: () -> Unit, onCycleView: () -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
val locale = currentLocale()
TopAppBar( TopAppBar(
title = { title = {
Text( CalendarTitleButton(
text = formatDayTitle(date), title = formatDayTitle(date, locale, currentYear),
style = MaterialTheme.typography.titleLarge, currentDate = date,
onJumpToDate = onJumpToDate,
) )
}, },
navigationIcon = { navigationIcon = {
@@ -376,6 +390,7 @@ private fun DayTopBar(
} }
}, },
actions = { actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) { IconButton(onClick = onOpenSearch) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
@@ -448,9 +463,11 @@ private fun AllDayBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = modifier modifier = modifier
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title }, .semantics { contentDescription = title },
@@ -461,7 +478,7 @@ private fun AllDayBar(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f), color = eventInk(fill),
) )
} }
} }
@@ -473,21 +490,32 @@ private fun Timeline(
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit, onCreateAt: (LocalDate, Int) -> Unit,
) { ) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale() val locale = currentLocale()
val zoom = LocalTimelineZoom.current
Box(modifier = Modifier.fillMaxSize()) { // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
// bar, date header and all-day strip.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val hourHeight = zoom.scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The pinch sits on the Row, above both scroll viewports: it has to
// outrank the vertical scroll, and it does that by watching the initial
// pass, which only reaches it if it is their ancestor.
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
// Gutter and day column are two scroll viewports that SHARE one scroll // Gutter and day column are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a // state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the // static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position. // soft corners are permanent at any scroll position.
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize().then(pinch)) {
// Hour gutter (scrolls in sync with the day column) // Hour gutter (scrolls in sync with the day column). Start inset so the
// labels centre on the top bar hamburger, matching the week view.
Column( Column(
modifier = Modifier modifier = Modifier
.width(GUTTER_WIDTH) .width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight() .fillMaxHeight()
.verticalScroll(scrollState), .verticalScroll(scrollState),
) { ) {
@@ -495,7 +523,7 @@ private fun Timeline(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(HOUR_HEIGHT), .height(hourHeight),
) { ) {
if (h > 0) { if (h > 0) {
Text( Text(
@@ -523,6 +551,7 @@ private fun Timeline(
dark = dark, dark = dark,
date = state.date, date = state.date,
today = state.today, today = state.today,
hourHeight = hourHeight,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateAt = onCreateAt, onCreateAt = onCreateAt,
modifier = Modifier modifier = Modifier
@@ -540,11 +569,12 @@ private fun DayColumnCard(
dark: Boolean, dark: Boolean,
date: LocalDate, date: LocalDate,
today: LocalDate, today: LocalDate,
hourHeight: Dp,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit, onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant val hourLineColor = MaterialTheme.colorScheme.outlineVariant
Card( Card(
@@ -573,14 +603,16 @@ private fun DayColumnCard(
}, },
) { ) {
val colWidth = maxWidth val colWidth = maxWidth
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
blocks.forEach { block -> blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount val laneWidth = colWidth / block.laneCount
val top = HOUR_HEIGHT * (block.startMin / 60f) val top = hourHeight * (block.startMin / 60f)
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f) val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
EventBlock( EventBlock(
block = block, block = block,
dark = dark, dark = dark,
height = height,
onClick = { onEventClick(block.event) }, onClick = { onEventClick(block.event) },
modifier = Modifier modifier = Modifier
.offset(x = laneWidth * block.lane, y = top) .offset(x = laneWidth * block.lane, y = top)
@@ -591,7 +623,7 @@ private fun DayColumnCard(
} }
// Current-time line, on top of the events, only on today's column. // Current-time line, on top of the events, only on today's column.
if (date == today) { if (date == today) {
NowLine(date = date, hourHeight = HOUR_HEIGHT) NowLine(date = date, hourHeight = hourHeight)
} }
} }
} }
@@ -601,6 +633,7 @@ private fun DayColumnCard(
private fun EventBlock( private fun EventBlock(
block: TimedBlock, block: TimedBlock,
dark: Boolean, dark: Boolean,
height: Dp,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -609,29 +642,46 @@ private fun EventBlock(
val locale = currentLocale() val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale) minToHm(block.endMin, use24Hour, locale)
val showTime = block.endMin - block.startMin >= 45 val density = LocalDensity.current
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// What's left for text once the 2.dp top/bottom padding is paid for. A block
// that cannot afford both lines spends its space on the title, and one too
// short even for that drops the title rather than serving a sliced one.
val available = height - 4.dp
val showTime = block.endMin - block.startMin >= 45 &&
available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
Box( Box(
modifier = modifier modifier = modifier
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp) .padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" }, .semantics { contentDescription = "$title, $timeLabel" },
) { ) {
Column { Column {
if (showTitle) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2, maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f), color = eventInk(fill, alpha = 0.85f),
) )
}
if (showTime) { if (showTime) {
Text( Text(
text = timeLabel, text = timeLabel,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f), color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
) )
} }
} }
@@ -640,8 +690,12 @@ private fun EventBlock(
@Composable @Composable
private fun DayLoading() { private fun DayLoading() {
val totalHeight = HOUR_HEIGHT * 24 val scale = LocalTimelineZoom.current.scale
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives.
val totalHeight = scale.hourHeight(maxHeight) * 24
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
Spacer(Modifier.width(GUTTER_WIDTH)) Spacer(Modifier.width(GUTTER_WIDTH))
Box( Box(
@@ -653,14 +707,15 @@ private fun DayLoading() {
) )
} }
} }
}
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String = private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale) formatMinuteOfDay(min, is24Hour, locale)
private fun formatDayTitle(date: LocalDate): String { private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
val locale = Locale.getDefault() formatCalendarTitle(
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale) locale = locale,
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale) currentYear = currentYear,
return "$weekday, ${date.day}. $monthName ${date.year}" skeleton = "EEEdMMM",
} )

View File

@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
@@ -87,25 +88,32 @@ import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeStatus import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.AttendeeType import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlin.time.toJavaInstant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant import kotlin.time.Instant
@@ -116,7 +124,10 @@ import kotlin.time.Instant
* top-bar arrow both return to the calendar. Events in writable calendars can * top-bar arrow both return to the calendar. Events in writable calendars can
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared * be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
* event form for this occurrence — for recurring events the form asks how * event form for this occurrence — for recurring events the form asks how
* far the change reaches when saving. * far the change reaches when saving. [onDuplicate] opens the same form
* seeded with a copy of this event as a new, unsaved event (#52) — offered
* for any event, including read-only ones, since the copy lands in an
* editable calendar.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -126,6 +137,7 @@ fun EventDetailScreen(
endMillis: Long, endMillis: Long,
onBack: () -> Unit, onBack: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onDuplicate: (EventForm) -> Unit,
viewModel: EventDetailViewModel = hiltViewModel(), viewModel: EventDetailViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(eventId, beginMillis, endMillis) { LaunchedEffect(eventId, beginMillis, endMillis) {
@@ -160,15 +172,14 @@ fun EventDetailScreen(
} }
// v1.0 installs only hold READ_CALENDAR; the first write asks for the // v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place. Granting continues straight into the tapped action. // upgrade in place. Granting continues straight into the tapped action
var pendingEdit by remember { mutableStateOf(false) } // edit, delete, or duplicate — held here until the result comes back.
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
val writePermissionLauncher = rememberLauncherForActivityResult( val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
) { granted -> ) { granted ->
if (granted) { if (granted) pendingWrite?.invoke()
if (pendingEdit) onEdit() else showDeleteDialog = true pendingWrite = null
}
pendingEdit = false
} }
val hasWritePermission = { val hasWritePermission = {
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
@@ -176,21 +187,20 @@ fun EventDetailScreen(
Manifest.permission.WRITE_CALENDAR, Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
} }
val onDeleteClick = { val requireWrite: (() -> Unit) -> Unit = { action ->
if (hasWritePermission()) { if (hasWritePermission()) {
showDeleteDialog = true action()
} else { } else {
pendingEdit = false pendingWrite = action
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR) writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
} }
} }
val onEditClick = { val onDeleteClick = { requireWrite { showDeleteDialog = true } }
if (hasWritePermission()) { val onEditClick = { requireWrite(onEdit) }
onEdit() // Duplicate reads the loaded detail into a create form and hands it up; the
} else { // create still needs WRITE_CALENDAR even when the source is read-only.
pendingEdit = true val onDuplicateClick = {
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR) requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
}
} }
val deleteFailedMessage = stringResource(R.string.event_delete_failed) val deleteFailedMessage = stringResource(R.string.event_delete_failed)
@@ -229,7 +239,8 @@ fun EventDetailScreen(
}, },
actions = { actions = {
val s = state val s = state
// Share works for any loaded event — it only reads the event. // Share and duplicate work for any loaded event — both only
// read it; the duplicate is created into a writable calendar.
if (s is EventDetailUiState.Success) { if (s is EventDetailUiState.Success) {
IconButton(onClick = onShareClick) { IconButton(onClick = onShareClick) {
Icon( Icon(
@@ -237,6 +248,15 @@ fun EventDetailScreen(
contentDescription = stringResource(R.string.event_detail_share), contentDescription = stringResource(R.string.event_detail_share),
) )
} }
IconButton(
onClick = onDuplicateClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(R.string.event_detail_duplicate),
)
}
} }
// Edit/delete need a writable calendar — WebCal subscriptions, // Edit/delete need a writable calendar — WebCal subscriptions,
// birthday calendars etc. are read-only at the provider level. // birthday calendars etc. are read-only at the provider level.
@@ -367,7 +387,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
val instance = detail.instance val instance = detail.instance
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val locale = currentDetailLocale() val locale = currentDetailLocale()
val accent = pastelize(instance.color, dark) val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
Column( Column(
modifier = modifier modifier = modifier
@@ -438,14 +458,44 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
} }
// Time zone — only when the event is timed and pinned to a zone other // Time zone — only when the event is timed and pinned to a zone other
// than the device's, so cross-zone events read unambiguously. // than the device's, so cross-zone events read unambiguously. It answers
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel -> // "when was it set", which the zone's name alone never did: an 8 AM New
// York call showing as 2 PM here should still say 8 AM somewhere.
//
// Same hierarchy as the When card above — the label carries the card,
// the time sits small beneath it. The local time is the one the reader
// acts on, so the original must stay quieter than it, not compete.
val foreignZone = remember(detail.eventTimezone, instance.isAllDay, instance.start, locale) {
foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale, instance.start)
}
foreignZone?.let { zoneOption ->
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( DetailCard(
icon = Icons.Default.Public, icon = Icons.Default.Public,
iconContentDescription = stringResource(R.string.event_detail_timezone), iconContentDescription = stringResource(R.string.event_detail_timezone),
) { ) {
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium) // The id is the primary label ("Europe/Berlin"); the long
// localized name spelled next to it made the field too wide.
Text(text = zoneOption.label, style = MaterialTheme.typography.titleMedium)
// Beneath, small: the abbreviation plus the event's own-zone time,
// e.g. "CET · 8:00 AM 9:00 AM". formatWhen puts the range in the
// secondary half only for a same-day event; one spanning midnight
// — which a cross-zone event easily does — carries the whole span
// in the primary instead, so fall back to it. If the zone can't be
// read back at all, drop to just the abbreviation + offset.
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
?.let { formatWhen(instance, it, locale) }
?.let { (primary, secondary) -> secondary ?: primary }
Spacer(Modifier.height(2.dp))
Text(
text = if (originalTime != null) {
"${zoneOption.shortName} · $originalTime"
} else {
zoneDescriptor(zoneOption)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
@@ -737,21 +787,30 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes) private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
/** /**
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"), * [tz] resolved as a [TimeZoneOption], but only when the event is timed and
* but only when the event is timed and pinned to a zone different from the * pinned to a zone different from the device's — the cases where showing it
* device's. Returns null when there's nothing worth showing. * removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the
* tz database doesn't know).
*
* Resolved *at [at]*, the event's own start, not at "now": the abbreviation and
* offset both move with DST, and the card prints them next to the event's time.
* Resolving at now would label a July event "CET · 10:00 AM" when read in
* January — an abbreviation that contradicts the time beside it.
*/ */
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? { private fun foreignTimeZone(
tz: String?,
isAllDay: Boolean,
locale: Locale,
at: Instant,
): TimeZoneOption? {
if (isAllDay || tz.isNullOrBlank()) return null if (isAllDay || tz.isNullOrBlank()) return null
val deviceZone = ZoneId.systemDefault().id if (tz == ZoneId.systemDefault().id) return null
if (tz == deviceZone) return null return timeZoneOptionOf(
return try { tz,
val zone = ZoneId.of(tz) locale,
val name = zone.getDisplayName(JavaTextStyle.FULL, locale) at = at.toJavaInstant(),
if (name == tz) tz else "$name ($tz)" regionOf = ::icuTimeZoneRegion,
} catch (e: Exception) { )
tz
}
} }
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */ /** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */

View File

@@ -8,10 +8,12 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.time.Clock import kotlin.time.Clock
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -138,6 +141,31 @@ class EventDetailViewModel @Inject constructor(
}.getOrNull() }.getOrNull()
} }
/**
* Build a create form seeded from the open event so the user can save a
* copy as a new, independent event (#52). Returns null when nothing is
* loaded.
*
* The recurrence is dropped — a duplicate is a single event; anyone who
* wants a series can re-add one in the edit form that opens. The source
* calendar is kept only when it's writable, otherwise the id is cleared so
* the create resolves to the last-used/first-writable calendar — which lets
* a read-only event (a WebCal subscription, a birthday) be copied into an
* editable calendar. The occurrence's own times carry over unchanged.
*/
fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null
val detail = loaded.detail
return detail.toEditForm(
beginMillis = detail.instance.start.toEpochMilliseconds(),
endMillis = detail.instance.end.toEpochMilliseconds(),
zone = TimeZone.currentSystemDefault(),
).copy(
rrule = null,
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
)
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try { private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId) val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this // The Events row holds the series start; replace it with this

Some files were not shown because too many files have changed in this diff Show More