diff --git a/.forgejo/ISSUE_TEMPLATE/bug_report.md b/.forgejo/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..6a4e08a --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/bug_report.md @@ -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: +- Android version: +- Device: +- Installed from: +- Affected calendar: +- Time zone: + diff --git a/.forgejo/ISSUE_TEMPLATE/config.yml b/.forgejo/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..2fc3df8 --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.gitea/ISSUE_TEMPLATE/crash_report.md b/.forgejo/ISSUE_TEMPLATE/crash_report.md similarity index 97% rename from .gitea/ISSUE_TEMPLATE/crash_report.md rename to .forgejo/ISSUE_TEMPLATE/crash_report.md index 6828788..4626077 100644 --- a/.gitea/ISSUE_TEMPLATE/crash_report.md +++ b/.forgejo/ISSUE_TEMPLATE/crash_report.md @@ -5,7 +5,7 @@ title: "Crash: " labels: - bug - crash - - priority:high + - priority/high --- + +### What this changes + + +### Why + + + + +### How it was tested + + + + +### 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 diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 2500323..0000000 --- a/.gitea/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -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: -- Android version: -- Device: diff --git a/.gitignore b/.gitignore index b170de3..3f3546f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,11 @@ google-services.json .DS_Store Thumbs.db +# Editor swap/backup files +*.swp +*.swo +*~ + # F-Droid local artifacts (the pipeline generates them in CI) /fdroid/ diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 04ce9d3..c9f5e5a 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -17,9 +17,10 @@ re-inventing the calendar sync stack — leave that to DAVx5 and the system. ## Current Milestone Milestones 1 (read, v1.0) and 2 (write support, v1.1–v2.0.0 incl. reminder -delivery) are **complete** — v2.0.0 shipped 2026-06-11. Next is v3.0 -(power-user features) plus an undecided "Locations & People" idea backlog; -see `ROADMAP.md`. +delivery) are **complete** — v2.0.0 shipped 2026-06-11. Everything since is +tracked as issues and milestones on Codeberg: +. A milestone maps to its +`release/vX.Y.Z` branch. ## 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 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 -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 — -any feature that would need one is an explicit product decision first. +The shared Material 3 Expressive kit, **floret-kit**, is a git submodule wired +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 @@ -40,5 +57,9 @@ shows a stylized "1" on a slate squircle. ## Source -Hosted on self-hosted Gitea, released through self-hosted F-Droid repo on -Hetzner. Same infrastructure as `HouseHoldKeaper`. +**Codeberg (`jlmakiola/calendula`) is canonical** — git, issues, PRs, tags and +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`. diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md deleted file mode 100644 index a6b970b..0000000 --- a/.planning/REQUIREMENTS.md +++ /dev/null @@ -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 diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md deleted file mode 100644 index a27a7dc..0000000 --- a/.planning/ROADMAP.md +++ /dev/null @@ -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` + `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` — 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 31–32 (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. diff --git a/.planning/STATE.md b/.planning/STATE.md deleted file mode 100644 index a2c9e67..0000000 --- a/.planning/STATE.md +++ /dev/null @@ -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.1–v2.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 2–4: 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.1–v2.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**. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5948114 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index f2e3b31..db48769 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,25 @@ without reinstalling. Or build from source — see below. ## 📚 Documentation +- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch - **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks - **[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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a8c3600..32ebb79 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,7 +20,7 @@ the package list (recurring writes, save conflicts, reminder delivery). repository tests. Instrumented tests are a last resort. 4. **No network.** The app declares no `INTERNET` permission. Anything that would need one is an explicit, documented product decision first - (see the roadmap's idea backlog). + (the crash reporter's web-issue path is the worked example). ## Layers @@ -62,9 +62,15 @@ flowchart TD - **`data/prefs/`** — DataStore-backed settings (theme, week start, form field defaults, reminders toggle) and small state (last-used calendar). - **`ui/`** — one package per screen, each with Screen + ViewModel + - UiState. Shared pieces in `ui/common/` (OptionCard — the app's only - sanctioned selection-dialog style —, recurrence humanizer, FAB column, - drawer, transitions). + UiState. Shared pieces in `ui/common/` (recurrence humanizer, FAB column, + drawer, transitions). Selection pickers are full-screen and come from + floret-kit (`FullScreenPicker` / `OptionPicker`); `AlertDialog` is reserved + for plain confirmations and the compact recurring-scope choosers. +- **`floret-kit/`** — the shared Material 3 Expressive kit for the Floret app + family, wired in as a git submodule *and* a Gradle composite build + (`includeBuild`), so it is compiled from source rather than resolved as a + dependency. Pickers, crash plumbing, and locale/time helpers live there; + changing them is a pull request against that repository plus a submodule bump. ## Navigation @@ -81,7 +87,7 @@ exactly like an event tap. ## Recurring writes The provider's invariants drive the design (learned the hard way, verified -on-device — see plan 03): +on-device): - Recurring rows carry `RRULE` + `DURATION` (no `DTEND`); one-off rows carry `DTEND`. @@ -167,6 +173,7 @@ JUnit 5 + Truth + Turbine on the JVM. The seams that make it work: `CalendarDataSource` is faked (`FakeCalendarDataSource` records writes), mappers parse `ColumnReader`/plain maps instead of cursors, domain logic (recurrence, validation, snapshots, write-value building) is pure. CI -(Gitea Actions) runs `lint test assembleDebug` once per pull request; merging a +(Forgejo Actions on Codeberg) runs `lint test assembleDebug` once per pull +request; merging a bumped `versionName` to `main` builds, signs, and publishes to the self-hosted F-Droid repo and then mints the `vX.Y.Z` tag + release. See docs/RELEASING.md. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 6524e94..9577701 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -1,12 +1,35 @@ # Building from source Calendula builds with the standard Android Gradle toolchain — no extra setup -beyond the SDK and a JDK. +beyond the SDK, a JDK, and the submodule. + +## Clone + +```bash +git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git +``` + +Calendula depends on **[floret-kit](https://codeberg.org/jlmakiola/floret-kit)**, +the shared Material 3 Expressive kit, as a git submodule wired in as a Gradle +composite build (`includeBuild("floret-kit")` in `settings.gradle.kts`) — it is +compiled from source, not resolved from a repository. A clone without the +submodule fails to configure. For an existing clone: + +```bash +git submodule update --init --recursive +``` ## Requirements -- **Android SDK 36+** -- **JDK 17** — if your default JDK is not 17, set `JAVA_HOME` explicitly. +- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. If your + default JDK differs, set `JAVA_HOME` explicitly. +- **Android SDK** — platform **37** (`compileSdk`) and **build-tools 36.0.0**. + `minSdk` is 29, `targetSdk` 36. + +The SDK is located via `ANDROID_HOME` (or `ANDROID_SDK_ROOT`), or via a +gitignored `local.properties` with `sdk.dir`. If you use `local.properties`, note +that the composite build needs **its own** copy at `floret-kit/local.properties`; +setting `ANDROID_HOME` covers both builds at once and is the simpler route. The Gradle wrapper is checked in, so you don't need a system Gradle. @@ -14,11 +37,44 @@ The Gradle wrapper is checked in, so you don't need a system Gradle. ```bash ./gradlew assembleDebug # debug APK → app/build/outputs/apk/debug/ -./gradlew test # JVM unit tests +./gradlew test # JVM unit tests (JUnit 5 + Truth + Turbine) ./gradlew lint # Android lint ``` +A single test class, or a pattern: + +```bash +./gradlew testDebugUnitTest --tests "de.jeanlucmakiola.calendula.domain.SimpleRecurrenceTest" +./gradlew testDebugUnitTest --tests "*SimpleRecurrence*" +``` + +Translation-key invariants (stale and orphaned keys are fatal; missing ones are +not) are checked outside Gradle: + +```bash +python3 scripts/check_translations.py +``` + +## What CI runs + +`.forgejo/workflows/ci.yaml` reports a single `CI` check per pull request on +Codeberg: the reproducible-release guard, then `lintDebug`, +`testDebugUnitTest`, `assembleDebug` and a Trivy filesystem scan. Pull requests +that touch only docs, F-Droid metadata or the licence skip the Android build and +still report green. + ## Release builds Release signing and the publishing pipeline are documented separately in -[RELEASING.md](RELEASING.md). +[RELEASING.md](RELEASING.md). To smoke-test a release candidate locally, the +`releaseTest` build type is an R8-shrunk twin of `release`, debug-signed with its +own `applicationId` suffix so it installs alongside the real app: + +```bash +scripts/verify-release.sh +``` + +## Contributing + +See [`../CONTRIBUTING.md`](../CONTRIBUTING.md) for the issue-first workflow, +which branch to target, and the architectural rules a change is reviewed against. diff --git a/docs/README.md b/docs/README.md index dddbcf7..79d7394 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,19 +4,22 @@ Where to look for what: | Document | What it is | |---|---| +| [`../CONTRIBUTING.md`](../CONTRIBUTING.md) | How to contribute: issue-first workflow, which branch to target, translations, the rules a change is reviewed against | +| [`BUILDING.md`](BUILDING.md) | Building from source: submodule, JDK/SDK requirements, Gradle tasks, what CI runs | | [`ARCHITECTURE.md`](ARCHITECTURE.md) | Orientation tour: principles, layers, navigation, recurring-write / conflict / reminder pipelines, testing | +| [`RELEASING.md`](RELEASING.md) | Release process: versioning, the merge-driven pipeline, the two-forge split, secrets, key custody | | [`../CHANGELOG.md`](../CHANGELOG.md) | Release history (Keep a Changelog, SemVer) | -| [`../.planning/ROADMAP.md`](../.planning/ROADMAP.md) | Living roadmap: shipped milestones, current scope, idea backlog | -| [`../.planning/PROJECT.md`](../.planning/PROJECT.md) | What the project is, stack, naming, infrastructure | -| [`../.planning/REQUIREMENTS.md`](../.planning/REQUIREMENTS.md) | Requirement checklist per milestone | -| [`../.planning/STATE.md`](../.planning/STATE.md) | Snapshot of where development currently stands | -| [`superpowers/specs/`](superpowers/specs/) | The original design spec (2026-06-08) — historical record, not updated | -| [`superpowers/plans/`](superpowers/plans/) | Per-milestone implementation plans with task checklists — historical record of how each slice was built, including provider lessons learned | +| [Issues](https://codeberg.org/jlmakiola/calendula/issues) + [milestones](https://codeberg.org/jlmakiola/calendula/milestones) | **The roadmap.** What's planned, in progress, and shipped — a milestone maps to its `release/vX.Y.Z` branch | +| [`../.planning/PROJECT.md`](../.planning/PROJECT.md) | What the project is: core value, stack + version pins, constraints, naming, forge/release infrastructure | +| [`design/`](design/) | Per-feature design notes kept for features whose provider behaviour is worth recording | | [`../fastlane/metadata/android/`](../fastlane/metadata/android/) | Store metadata (single source of truth): descriptions, title, icon, screenshots (DE + EN). Harvested directly by the official F-Droid repo; transformed into the self-hosted repo layout at release time by [`../scripts/fastlane_to_fdroid_localized.sh`](../scripts/fastlane_to_fdroid_localized.sh) | | [`../fdroid-metadata/`](../fdroid-metadata/) | App-level F-Droid control file (`*.yml`: Categories, License, links) for the self-hosted repo's `fdroid update` | -| [`fdroid-official/`](fdroid-official/) | Draft recipe + notes for publishing to the **official** F-Droid repo (reproducible build + developer-signed binary) | +| [`fdroid-official/`](fdroid-official/) | Recipe + notes for publishing to the **official** F-Droid repo (reproducible build + developer-signed binary) | -Conventions: plans and specs under `superpowers/` are point-in-time -artifacts of the agentic workflow that built each milestone — they get -status updates but are never rewritten. The `.planning/` files are living -documents and should stay current. +Conventions: planning lives in the **issue tracker**, not in this repository. The +`.planning/` files that predated it (a roadmap, a development-state snapshot, and +a per-milestone requirement checklist) are gone — issues and milestones say the +same thing without going stale. `PROJECT.md` is what remains, and it describes +the project rather than its plan. +`ARCHITECTURE.md` is the authoritative orientation tour: it is updated with the +code, and is the right place for a lesson learned about the calendar provider.