Compare commits

...

25 Commits

Author SHA1 Message Date
54b059e744 chore(release): prepare v2.10.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m6s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:31:24 +02:00
d0c01ab8c4 Merge pull request 'feat(calendars): in-app enable/disable for calendars' (!38) from feat/disable-calendar into release/v2.10.0
Reviewed-on: #38
2026-06-25 12:28:31 +00:00
bf10fee19f Merge remote-tracking branch 'origin/release/v2.10.0' into feat/disable-calendar
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 8m57s
# Conflicts:
#	CHANGELOG.md
2026-06-25 14:23:07 +02:00
2e88c40eff Merge pull request 'feat(about): add Ko-fi support link' (!39) from feat/kofi-support into release/v2.10.0
Reviewed-on: #39
2026-06-25 12:21:37 +00:00
26e538a9ba docs(changelog): note Ko-fi support link
All checks were successful
Translations / check (pull_request) Successful in 7s
CI / ci (pull_request) Successful in 9m16s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:07:40 +02:00
814daa8151 docs(changelog): note in-app calendar enable/disable
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m49s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:06:39 +02:00
450fc7f7af Merge remote-tracking branch 'origin/main' into feat/disable-calendar 2026-06-25 14:05:45 +02:00
be6d10d126 feat(calendars): add app-side enable/disable for calendars
Introduce a second, heavier visibility level above the per-view hide
filter. A disabled calendar is removed from the app's surfaces entirely —
its events drop out of all views and search, and it disappears from the
drawer filter list, the event-form calendar picker and the import target
picker. It stays listed only in Settings → Calendars, where a per-row
switch toggles it, so it can always be brought back.

- CalendarPrefs: disabledCalendarIds set + setter, mirroring the hidden
  set (DataStore comma-separated string); never touches the system
  VISIBLE/SYNC_EVENTS flags, so it's app-local and reversible.
- CalendarRepositoryImpl: instances()/searchEvents() exclude
  calendarId ∈ (hidden ∪ disabled). distinctUntilChanged() on instances
  collapses the transient duplicate emission both DataStore-derived sets
  produce when either is toggled.
- FilterViewModel: drop disabled calendars from the drawer filter list.
- EventEditViewModel: exclude disabled from writableCalendars; a
  last-used preselect on a now-disabled calendar falls back to the first
  remaining writable one.
- ImportViewModel: exclude disabled from the import target list.
- CalendarsScreen/ViewModel: per-row enable/disable Switch on both the
  local and synced groups; disabled rows render dimmed (new GroupedRow
  `dimmed` flag) while keeping the toggle live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:55:10 +02:00
a1e8c05c7b docs(roadmap): mark v2.8.0 work shipped, capture disable-calendar
Update roadmap to reflect v2.8.0 releases (attendee editing, full-text
search, QS tile, now-line, contact pickers) and the week-numbers-in-month
rejection; capture the in-app 'disable a calendar' feature plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:39:01 +02:00
cecd0c2234 docs(readme): revert F-Droid badge to height 56
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:34:27 +02:00
43eb5e23e8 docs(readme): enlarge F-Droid badge to height 80
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:33:41 +02:00
946addbbcd docs(readme): use official 'Support me on Ko-fi' beige badge
Swap the Buy-me-a-coffee pill for Ko-fi's beige support badge, matched
to the F-Droid badge height (56).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:32:21 +02:00
8a63aa86a7 Update README.md 2026-06-25 11:28:44 +00:00
0ddbbb11e8 Update README.md 2026-06-25 11:28:19 +00:00
575b1c7eb7 Update ReadMe.md 2026-06-25 11:25:55 +00:00
dd61e0ed37 docs(readme): shrink Ko-fi button for visual balance
The solid Ko-fi pill read heavier than the F-Droid badge at equal
height; drop it to 46 so the two feel balanced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:23:13 +02:00
376ad5f394 docs(readme): put F-Droid and Ko-fi buttons inline at equal height
Both at height 56 on one line — equal heights align on the baseline,
so no table/border workaround is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:19:57 +02:00
8a9e6f0ee8 docs(readme): stack F-Droid and Ko-fi buttons, drop bordered table
The table centered them but Gitea draws cell borders we can't strip.
Stack the two badges on separate centered lines instead — no borders,
each keeps its own size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:09:29 +02:00
913b49e962 docs(readme): vertically center the F-Droid and Ko-fi buttons
Wrap both badges in a table row so the different-height buttons
align on a shared midline instead of the text baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:08:12 +02:00
96f4d4dc84 docs(readme): restore F-Droid badge to original height
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:07:04 +02:00
f4789c535e docs(readme): align Ko-fi button with F-Droid badge
Use Ko-fi's raster PNG button (the SVG was ignored and rendered
oversized) and match both buttons to height 56 so they sit inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:06:03 +02:00
00220c5f0e docs(readme): use official Ko-fi button next to F-Droid badge
Replace the small shields Ko-fi badge with Ko-fi's official
"Support me" button, placed beside the Get it on F-Droid badge
as a peer call-to-action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:04:15 +02:00
b7a5f16c84 docs(readme): replace inline sections with doc links
Move the Building section into docs/BUILDING.md and collapse Building,
Architecture, and Roadmap into a compact Documentation block linking to
the dedicated docs, keeping the README focused on what/install/support.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:56:26 +02:00
2c38912834 docs(readme): surface Ko-fi + official F-Droid, tidy install
- Move support to the top: Ko-fi badge in the header badge row,
  drop the redundant bottom Support section
- Add "Get it on F-Droid" button now that Calendula is on the
  official repo
- Restructure Install: lead with official F-Droid, keep the
  self-hosted repo as the faster-updates lane

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:53:01 +02:00
a9c67a5827 feat(about): add Ko-fi support link
Add an optional way for users to support development:

- About card: tonal "Support development" button opening ko-fi.com
  via ACTION_VIEW (no INTERNET permission), EN + DE strings
- F-Droid metadata: Donate field in self-hosted and official drafts
- README: short Support section with the Ko-fi link

No perks/rewards attached — kept as a plain donation link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:39:49 +02:00
21 changed files with 484 additions and 115 deletions

View File

@@ -230,7 +230,7 @@ pass on the existing controls; new toggles ride in with their own features.
**Tier 3 — platform reach (depends on Tier 2)** **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)* 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)*; optional quick-settings tile still open 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)* **Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global 9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
@@ -251,37 +251,38 @@ pass on the existing controls; new toggles ride in with their own features.
decision (snooze must re-fire an alarm). decision (snooze must re-fire an alarm).
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)* **Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
12. **Attendee editing** *(next — promoted from the gated "Locations & People" 12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
bucket, owner says high-importance 2026-06-22)* — attendees are already MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
*read* (queried, mapped, shown on the detail screen since v0.6) but the the event model: attendees were already *read* (queried, mapped, shown on the
event form can't *write* them, the last big read-only gap in the event detail screen since v0.6) and are now *writable* from the form. An attendees
model. Add an attendees section to `EventEditScreen` / `EventForm`: add by section on `EventEditScreen` / `EventForm` adds by typed email **or** the
typed email (contact-picker entry can follow later), edit/remove rows, set no-permission contact picker, edits/removes rows, and sets role (required /
role (required / optional) — writing `CalendarContract.Attendees` rows on optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
insert + dirty-checked update, mirroring the reminders-diff pattern. update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
**Caveat to resolve first:** writing `Attendees` rows triggers sync-adapter caveat was **settled record-only** (Calendula never sends invites; the backend
*invitation* behavior that differs by backend (Google auto-emails invites; decides delivery) — full sketch + decision in "Attendee editing" under Locations
DAVx5/CalDAV writes the `ATTENDEE` property and lets the server decide) — & People below.
decide and document what Calendula promises before building. Full sketch in
"Attendee editing" under Locations & People below.
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)** **Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage) - Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET - 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) - Move event to another calendar — sync-adapter minefield (copy+delete model)
**Bottom — deprioritised, not important** **Bottom — deprioritised, not important**
- Duplicate event (detail action → prefilled create form) — moved here - Duplicate event (detail action → prefilled create form) — moved here
2026-06-17; cheap but low value, pick up only if asked 2026-06-17; cheap but low value, pick up only if asked
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts, **Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
full-text search. Pulled in opportunistically, not sequenced. Pulled in opportunistically, not sequenced.
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics` 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). export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
**Next committed work is Tier 5 #12 — attendee editing** (the last read-only gap and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
in the event model). After it: the theme-group ideas and Tier 2/3 leftovers read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers
(quick-settings tile, now-line, week numbers in month, full-text search). 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 ## Navigation & views
@@ -291,24 +292,22 @@ in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
- Agenda view (fourth view: upcoming events grouped by day; also the - Agenda view (fourth view: upcoming events grouped by day; also the
natural data source for a future widget) natural data source for a future widget)
- Jump to date — drawer date picker (un-cut from V1) - Jump to date — drawer date picker (un-cut from V1)
- Current-time "now" line in day/week — standard in every calendar, cheap, - ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
currently absent. Daily-driver polish. - Week numbers in the **month** grid — **rejected** (owner decision): clutters
- Week numbers in the **month** grid — week view already shows the badge the view and shrinks the day cells; the badge stays week-view-only.
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
- Pinch-to-zoom time scale in day/week - Pinch-to-zoom time scale in day/week
- Tablet / foldable layouts *(was v3.0)* - Tablet / foldable layouts *(was v3.0)*
- Full-text search *(was v3.0)* — promote out of "fill-in": for a daily driver - ~~Full-text search~~ **shipped v2.8.0** for a daily driver with real event
with real event history, finding an event is core completeness, not optional. history, finding an event is core completeness, not optional.
## Event editing & creation ## Event editing & creation
- Duplicate event (detail action → prefilled create form) - Duplicate event (detail action → prefilled create form)
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form) - ~~**Per-event color**~~ *(shipped v2.4.0)*`EVENT_COLOR` / `EVENT_COLOR_KEY`
*(next)* — chosen to follow the in-progress tap-to-create + calendar from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
management work: reuses the color-picker component and palette plumbing picker in the form, falling back to the calendar color when unset. Reused the
being built for local calendar management, and finishes the create/edit color-picker component and palette plumbing from local calendar management and
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list finished the create/edit theme.
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
## Calendars & accounts ## Calendars & accounts
@@ -337,7 +336,63 @@ in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by `.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
UID). Closed the silent data-loss gap. UID). Closed the silent data-loss gap.
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)* ### 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 Two themes bundled because both are "make reminders trustworthy" — the core of
the "Calendula is your only calendar app" promise. the "Calendula is your only calendar app" promise.
@@ -414,18 +469,20 @@ it directly undermines the feature's premise, so it rides in here.
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
are notorious for suppressing it). are notorious for suppressing it).
### Open decisions (resolve before building) ### Decisions made (as shipped in v2.6.0)
1. Self-schedule via `AlarmManager` vs trust the provider broadcast 1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
(reliability vs simplicity + battery cost). battery-optimisation exemption is the reliability lever (simplicity + battery
2. All-day reminder representation (minutes-before vs absolute time-of-day). cost won over the exact-alarm subsystem).
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs 2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
a list inside the Notifications sub-page). 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.
### Later (round two) ### Round two
- Snooze + dismiss actions on the notification (snooze needs an - ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
exact-alarm / WorkManager decision) — Tier 4 #13. Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
## Sharing & interop ## Sharing & interop
@@ -438,8 +495,8 @@ it directly undermines the feature's premise, so it rides in here.
## Platform & launchers ## Platform & launchers
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets - ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0** - ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
optional quick-settings tile still open - ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
## Quality & reliability ## Quality & reliability
@@ -455,9 +512,10 @@ it directly undermines the feature's premise, so it rides in here.
Beyond classic calendar-client scope; discussed, deliberately not planned Beyond classic calendar-client scope; discussed, deliberately not planned
in detail yet: in detail yet:
- **Contact address picker** for the location field via the system picker - ~~**Contact address picker** for the location field via the system picker
(`ACTION_PICK` on postal addresses) — one-shot, needs no READ_CONTACTS, (`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
fits the privacy story. Same mechanism later for picking emails. 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 - **OSM address autocomplete** in the location field (type "Brandenburger
Tor" → tap suggestion → resolved address inserted). Backend would be Tor" → tap suggestion → resolved address inserted). Backend would be
Photon (Nominatim's public policy forbids autocomplete). **Requires the Photon (Nominatim's public policy forbids autocomplete). **Requires the

View File

@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.10.0] — 2026-06-25
### Added
- Turn a calendar off across the whole app. Settings → Calendars now has a
switch on every calendar — both your own and synced ones. Switching one off
removes it everywhere: its events disappear from all views and search, and it
drops out of the drawer's hide/show filter, the event-form calendar picker and
the import target picker. Unlike hiding (the quick per-view checkbox in the
drawer), a disabled calendar leaves the app entirely until you turn it back on.
Nothing is deleted and no other app is affected — it's a Calendula-only view
choice — so you can re-enable it any time from the same screen, where disabled
calendars stay listed but dimmed.
- An optional way to support development. Settings → About now has a "Support
development" button that opens Ko-fi in your browser. It's a plain donation
link with no perks attached, and it needs no new permissions — Calendula still
has no internet access of its own and just hands the link to your browser.
## [2.9.0] — 2026-06-25 ## [2.9.0] — 2026-06-25
### Added ### Added

View File

@@ -15,6 +15,12 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
</p> </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>
&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>
</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="19%" 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="19%" alt="Month view">&nbsp;
@@ -68,13 +74,20 @@ database, no sync stack reinvented.
## 📦 Install ## 📦 Install
Calendula ships through a self-hosted F-Droid repository; every version tag ### F-Droid (recommended)
is built, signed, and published there automatically.
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo Calendula is on the **official [F-Droid](https://f-droid.org) repository**
Store, …). just search for **Calendula** in any F-Droid client, or
2. Add the repository — open this link on your phone, or paste it under [install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
*Settings → Repositories → Add*:
### Self-hosted repo (latest builds)
New versions are built, signed, and published to a self-hosted repository the
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:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone):
``` ```
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425 https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
@@ -84,36 +97,16 @@ is built, signed, and published there automatically.
fingerprint (SHA-256): fingerprint (SHA-256):
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub> `C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
3. Refresh, search for **Calendula**, install. Updates arrive like any 2. Refresh, search for **Calendula**, install.
other F-Droid app.
Alternatively, build from source — see below. Both channels share the same signing key, so you can switch between them
without reinstalling. Or build from source — see below.
## 🛠 Building ## 📚 Documentation
Requires Android SDK 36+ and JDK 17. The Gradle wrapper is checked in: - **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
```bash - **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
./gradlew assembleDebug # debug APK
./gradlew test # JVM unit tests
./gradlew lint # Android lint
```
If your default JDK is not 17, set `JAVA_HOME` explicitly.
## 🏗 Architecture
Single-activity Compose app, layered `UI → Repository → DataSource →
CalendarContract`, observer-driven refresh, JVM-first tests. The full tour —
including the recurring-write and reminder pipelines — lives in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## 🗺 Roadmap
Shipped: read (v1.0), write (v1.1v2.0), reminder delivery (v1.4).
Next up: power-user features — widget, search, tablet layouts. The living
roadmap is in [.planning/ROADMAP.md](.planning/ROADMAP.md), the release
history in [CHANGELOG.md](CHANGELOG.md).
## 📜 License ## 📜 License

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 = 20900 versionCode = 21000
versionName = "2.9.0" versionName = "2.10.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow 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.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
@@ -55,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor(
.reQuery { dataSource.calendars() } .reQuery { dataSource.calendars() }
.flowOn(io) .flowOn(io)
// Instances are filtered by the app-side hidden-calendar set (M3): an event // Instances are filtered by the app-side hidden disabled calendar sets
// is dropped whenever the user has hidden its calendar. Re-runs when the // (M3): an event is dropped whenever the user has hidden *or* disabled its
// provider ticks *or* the hidden set changes — toggling a calendar in the // calendar. Re-runs when the provider ticks *or* either set changes —
// filter sheet updates every view immediately. [calendars] stays unfiltered // toggling a calendar in the filter sheet or the calendar manager updates
// so the filter sheet can list and re-enable hidden calendars. // 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>> = override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
combine( combine(
ticks ticks
@@ -71,10 +73,17 @@ class CalendarRepositoryImpl @Inject constructor(
) )
}, },
prefs.hiddenCalendarIds, prefs.hiddenCalendarIds,
) { instances, hidden -> prefs.disabledCalendarIds,
if (hidden.isEmpty()) instances ) { instances, hidden, disabled ->
else instances.filterNot { it.calendarId in hidden } val excluded = hidden + disabled
}.flowOn(io) if (excluded.isEmpty()) instances
else instances.filterNot { it.calendarId in excluded }
}
// 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()
.flowOn(io)
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) ?: throw NoSuchEventException(eventId)
@@ -82,9 +91,9 @@ class CalendarRepositoryImpl @Inject constructor(
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 hidden = prefs.hiddenCalendarIds.first() val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
dataSource.searchEvents(query) dataSource.searchEvents(query)
.let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } } .let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
} }
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> = override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =

View File

@@ -39,6 +39,31 @@ class CalendarPrefs @Inject constructor(
} }
} }
/**
* App-side preference for "calendars the user has disabled in this app" — a
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
* every surface (drawer filter, event-form picker, import picker) and its
* 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 ->
prefs[DISABLED_IDS_KEY].orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
}
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
store.edit { prefs ->
if (ids.isEmpty()) {
prefs.remove(DISABLED_IDS_KEY)
} else {
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
}
}
}
/** /**
* The calendar the user last created an event in; preselected in the * The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created. * event form. Null until the first event is created.
@@ -53,6 +78,7 @@ 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 LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id") internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
} }
} }

View File

@@ -30,7 +30,6 @@ import androidx.compose.material.icons.filled.Add
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.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
@@ -45,6 +44,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -58,11 +58,14 @@ 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.clip 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.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
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
@@ -98,6 +101,7 @@ fun CalendarsScreen(
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 error by viewModel.error.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
@@ -134,6 +138,7 @@ 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, backupResult = backupResult,
@@ -142,6 +147,7 @@ fun CalendarsScreen(
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,
) )
} }
} }
@@ -150,6 +156,7 @@ 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?, backupResult: BackupResult?,
@@ -158,6 +165,7 @@ private fun CalendarsList(
onBack: () -> Unit, onBack: () -> Unit,
onAdd: () -> Unit, onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit, onEdit: (CalendarSource) -> Unit,
onSetDisabled: (Long, Boolean) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -206,19 +214,21 @@ private fun CalendarsList(
if (local.isEmpty()) { if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty)) HintText(stringResource(R.string.calendars_local_empty))
} }
HintText(stringResource(R.string.calendars_disable_hint))
val localCount = local.size + 1 val localCount = local.size + 1
local.forEachIndexed { index, calendar -> local.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
summary = calendar.description, summary = calendar.description,
position = positionOf(index, localCount), position = positionOf(index, localCount),
leading = { CalendarColorChip(calendar.color) }, dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = { trailing = {
Icon( EnableSwitch(
Icons.Default.Edit, calendarName = calendar.displayName,
contentDescription = stringResource(R.string.calendars_edit_title), enabled = !disabled,
tint = MaterialTheme.colorScheme.onSurfaceVariant, onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
modifier = Modifier.size(20.dp),
) )
}, },
onClick = { onEdit(calendar) }, onClick = { onEdit(calendar) },
@@ -258,10 +268,19 @@ private fun CalendarsList(
.forEach { (account, cals) -> .forEach { (account, cals) ->
AccountHeader(account = account, accountType = cals.first().accountType) AccountHeader(account = account, accountType = cals.first().accountType)
cals.forEachIndexed { index, calendar -> cals.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
position = positionOf(index, cals.size), position = positionOf(index, cals.size),
leading = { CalendarColorChip(calendar.color) }, dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName,
enabled = !disabled,
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
)
},
) )
} }
} }
@@ -424,6 +443,30 @@ private fun CalendarEditor(
} }
} }
/**
* The per-row enable/disable control. Checked = the calendar is shown in the
* app; unchecking disables it (events, filters and pickers all drop it) without
* 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 EnableSwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
/** Fade a leading element to the M3 disabled emphasis when [disabled]. */
private fun dimIf(disabled: Boolean): Modifier =
if (disabled) Modifier.alpha(0.38f) else Modifier
/** Tonal field card matching the event editor's design (icon + content). */ /** Tonal field card matching the event editor's design (icon + content). */
@Composable @Composable
private fun EditorCard( private fun EditorCard(

View File

@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository 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.CalendarPrefs
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
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
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
@@ -33,6 +35,7 @@ import javax.inject.Inject
class CalendarsViewModel @Inject constructor( class CalendarsViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val icsExporter: IcsExporter, private val icsExporter: IcsExporter,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -46,6 +49,20 @@ 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(),
)
private val _error = MutableStateFlow(false) private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow() val error: StateFlow<Boolean> = _error.asStateFlow()
@@ -93,6 +110,19 @@ class CalendarsViewModel @Inject constructor(
repository.deleteCalendar(id) repository.deleteCalendar(id)
} }
/**
* Enable or disable a calendar app-side. Disabling removes it from every
* surface but Settings → Calendars (and hides its events) without touching
* provider data — purely a reversible Calendula-local view choice.
*/
fun setDisabled(id: Long, disabled: Boolean) {
viewModelScope.launch {
val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + id else current - id
if (next != current) prefs.setDisabledCalendarIds(next)
}
}
private inline fun write(crossinline block: suspend () -> Unit) { private inline fun write(crossinline block: suspend () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
try { try {

View File

@@ -127,7 +127,9 @@ fun CollapsingScaffold(
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose * One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
* corner radii come from its [position] (so a run of rows reads as a single * corner radii come from its [position] (so a run of rows reads as a single
* rounded card). Corners round further on press. A null [onClick] makes the * rounded card). Corners round further on press. A null [onClick] makes the
* row non-interactive (e.g. read-only entries). * row non-interactive (e.g. read-only entries). [dimmed] fades the headline and
* summary to the M3 disabled emphasis while leaving the [trailing] control at
* full opacity — for rows that are present but switched off.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -137,6 +139,7 @@ fun GroupedRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
summary: String? = null, summary: String? = null,
selected: Boolean = false, selected: Boolean = false,
dimmed: Boolean = false,
minHeight: Dp = 72.dp, minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null, leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null,
@@ -168,6 +171,16 @@ fun GroupedRow(
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer, supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
) )
} else if (dimmed) {
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
// stays full-opacity so the toggle reads as live even on a faded row.
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = muted,
leadingIconColor = muted,
supportingColor = muted,
)
} else { } else {
ListItemDefaults.colors(containerColor = Color.Transparent) ListItemDefaults.colors(containerColor = Color.Transparent)
} }

View File

@@ -121,10 +121,18 @@ class EventEditViewModel @Inject constructor(
val allowColorOnUnsupported: Boolean, val allowColorOnUnsupported: Boolean,
) )
/** Writable calendars — the only valid event targets. */ /**
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars() * Writable calendars — the only valid event targets. Disabled calendars are
.map { calendars -> calendars.filter { it.canModifyContents } } * excluded, so you can't create into a calendar you've removed from the app;
.catch { emit(emptyList()) } * a last-used preselect landing on a now-disabled calendar falls back to the
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
*/
private val writableCalendars: Flow<List<CalendarSource>> = combine(
repository.calendars(),
prefs.disabledCalendarIds,
) { calendars, disabled ->
calendars.filter { it.canModifyContents && it.id !in disabled }
}.catch { emit(emptyList()) }
/** The target calendar id, resolved exactly as the form shows it. */ /** The target calendar id, resolved exactly as the form shows it. */
private val resolvedCalendarId: Flow<Long?> = combine( private val resolvedCalendarId: Flow<Long?> = combine(

View File

@@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor(
combine( combine(
repository.calendars(), repository.calendars(),
prefs.hiddenCalendarIds, prefs.hiddenCalendarIds,
) { calendars, hidden -> prefs.disabledCalendarIds,
if (calendars.isEmpty()) { ) { calendars, hidden, disabled ->
// Disabled calendars are gone from the app entirely — they don't
// belong in the drawer's hide/show list (you can't hide what's
// already disabled). They live only in Settings → Calendars.
val enabled = calendars.filterNot { it.id in disabled }
if (enabled.isEmpty()) {
FilterUiState.Failure(FailureReason.NoCalendarsConfigured) FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
} else { } else {
FilterUiState.Success(groupByAccount(calendars, hidden)) FilterUiState.Success(groupByAccount(enabled, hidden))
} }
} }
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }

View File

@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository 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.IcsImporter import de.jeanlucmakiola.calendula.data.ics.IcsImporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
@@ -56,6 +57,7 @@ sealed interface ImportUiState {
class ImportViewModel @Inject constructor( class ImportViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val importer: IcsImporter, private val importer: IcsImporter,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -79,11 +81,17 @@ class ImportViewModel @Inject constructor(
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()), form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
warnings = parsed.warnings, warnings = parsed.warnings,
) )
else -> ImportUiState.Many( else -> {
events = parsed.events, // A disabled calendar is removed from the app, so it can't be
warnings = parsed.warnings, // an import target — exclude it alongside the read-only ones.
calendars = repository.calendars().first().filter { it.canModifyContents }, val disabled = prefs.disabledCalendarIds.first()
) ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter { it.canModifyContents && it.id !in disabled },
)
}
} }
} }
} }

View File

@@ -42,11 +42,13 @@ import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Notifications 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.Tune import androidx.compose.material.icons.filled.Tune
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.OutlinedButton import androidx.compose.material3.OutlinedButton
@@ -292,6 +294,7 @@ private fun AboutCard() {
val context = LocalContext.current val context = LocalContext.current
val sourceUrl = stringResource(R.string.about_source_url) val sourceUrl = stringResource(R.string.about_source_url)
val licenseUrl = stringResource(R.string.about_license_url) val licenseUrl = stringResource(R.string.about_license_url)
val supportUrl = stringResource(R.string.about_support_url)
Surface( Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -350,6 +353,19 @@ private fun AboutCard() {
Text(stringResource(R.string.settings_license)) Text(stringResource(R.string.settings_license))
} }
} }
Spacer(Modifier.height(8.dp))
FilledTonalButton(
onClick = { openUrl(context, supportUrl) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(
Icons.Default.Favorite,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_about_support))
}
} }
} }
} }

View File

@@ -315,6 +315,7 @@
<string name="settings_license_value">MIT</string> <string name="settings_license_value">MIT</string>
<string name="settings_about_author">von Jean-Luc Makiola</string> <string name="settings_about_author">von Jean-Luc Makiola</string>
<string name="settings_about_source">Quellcode</string> <string name="settings_about_source">Quellcode</string>
<string name="settings_about_support">Entwicklung unterstützen</string>
<string name="settings_about_version">Version %1$s</string> <string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula-App-Symbol</string> <string name="settings_about_logo_desc">Calendula-App-Symbol</string>
<string name="settings_report_problem">Problem melden</string> <string name="settings_report_problem">Problem melden</string>

View File

@@ -308,6 +308,7 @@
<string name="settings_license_value">MIT</string> <string name="settings_license_value">MIT</string>
<string name="settings_about_author">by Jean-Luc Makiola</string> <string name="settings_about_author">by Jean-Luc Makiola</string>
<string name="settings_about_source">Source</string> <string name="settings_about_source">Source</string>
<string name="settings_about_support">Support development</string>
<string name="settings_about_version">Version %1$s</string> <string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula app icon</string> <string name="settings_about_logo_desc">Calendula app icon</string>
<string name="settings_report_problem">Report a problem</string> <string name="settings_report_problem">Report a problem</string>
@@ -318,6 +319,8 @@
<string name="calendars_local_header">Your calendars</string> <string name="calendars_local_header">Your calendars</string>
<string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string> <string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string>
<string name="calendars_add">Add calendar</string> <string name="calendars_add">Add calendar</string>
<string name="calendars_disable_hint">Turn a calendar off to remove it from the app — its events, filters and pickers. Nothing is deleted, and you can turn it back on here anytime.</string>
<string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string>
<string name="calendars_synced_header">Synced calendars</string> <string name="calendars_synced_header">Synced calendars</string>
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string> <string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
<string name="calendars_manage_in_app">Manage</string> <string name="calendars_manage_in_app">Manage</string>
@@ -378,6 +381,7 @@
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string> <string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string> <string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
<!-- Crash reporting: a captured report the user can submit, by hand, as a <!-- Crash reporting: a captured report the user can submit, by hand, as a
Gitea issue (the app sends nothing automatically). --> Gitea issue (the app sends nothing automatically). -->

View File

@@ -170,6 +170,74 @@ class CalendarRepositoryImplTest {
} }
} }
@Test
fun `instances drops events whose calendar the user disabled`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
prefs.setDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "Enabled", calendarId = 1L),
makeEvent(11L, "Disabled", calendarId = 2L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("Enabled")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances applies the union of hidden and disabled sets`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(2L))
prefs.setDisabledCalendarIds(setOf(3L))
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "Shown", calendarId = 1L),
makeEvent(11L, "Hidden", calendarId = 2L),
makeEvent(12L, "Disabled", calendarId = 3L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("Shown")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances re-emits when the disabled set changes`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "A", calendarId = 1L),
makeEvent(11L, "B", calendarId = 2L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
prefs.setDisabledCalendarIds(setOf(2L))
assertThat(awaitItem().map { it.title }).containsExactly("A")
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest { fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L } val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }

View File

@@ -50,4 +50,34 @@ class CalendarPrefsTest {
} }
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L)) assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L))
} }
@Test
fun `disabledCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
}
@Test
fun `setDisabledCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setDisabledCalendarIds(setOf(1L, 42L, 7L))
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
}
@Test
fun `setting empty disabled set clears storage`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setDisabledCalendarIds(setOf(1L))
prefs.setDisabledCalendarIds(emptySet())
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
}
@Test
fun `hidden and disabled sets are stored independently`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setHiddenCalendarIds(setOf(1L))
prefs.setDisabledCalendarIds(setOf(2L))
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L))
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(2L))
}
} }

24
docs/BUILDING.md Normal file
View File

@@ -0,0 +1,24 @@
# Building from source
Calendula builds with the standard Android Gradle toolchain — no extra setup
beyond the SDK and a JDK.
## Requirements
- **Android SDK 36+**
- **JDK 17** — if your default JDK is not 17, set `JAVA_HOME` explicitly.
The Gradle wrapper is checked in, so you don't need a system Gradle.
## Common tasks
```bash
./gradlew assembleDebug # debug APK → app/build/outputs/apk/debug/
./gradlew test # JVM unit tests
./gradlew lint # Android lint
```
## Release builds
Release signing and the publishing pipeline are documented separately in
[RELEASING.md](RELEASING.md).

View File

@@ -21,6 +21,7 @@ AuthorName: Jean-Luc Makiola
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
Donate: https://ko-fi.com/jeanlucmakiola
AutoName: Calendula AutoName: Calendula

View File

@@ -0,0 +1,14 @@
### Added
- Turn a calendar off across the whole app. Settings → Calendars now has a
switch on every calendar — both your own and synced ones. Switching one off
removes it everywhere: its events disappear from all views and search, and it
drops out of the drawer's hide/show filter, the event-form calendar picker and
the import target picker. Unlike hiding (the quick per-view checkbox in the
drawer), a disabled calendar leaves the app entirely until you turn it back on.
Nothing is deleted and no other app is affected — it's a Calendula-only view
choice — so you can re-enable it any time from the same screen, where disabled
calendars stay listed but dimmed.
- An optional way to support development. Settings → About now has a "Support
development" button that opens Ko-fi in your browser. It's a plain donation
link with no perks attached, and it needs no new permissions — Calendula still
has no internet access of its own and just hands the link to your browser.

View File

@@ -8,3 +8,4 @@ Categories:
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
Donate: https://ko-fi.com/jeanlucmakiola