From a9c67a5827a433bbbb7292cf79d576c3ed0e9a44 Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 12:39:49 +0200
Subject: [PATCH 01/21] feat(about): add Ko-fi support link
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
README.md | 11 +++++++++++
.../calendula/ui/settings/SettingsScreen.kt | 16 ++++++++++++++++
app/src/main/res/values-de/strings.xml | 1 +
app/src/main/res/values/strings.xml | 2 ++
.../de.jeanlucmakiola.calendula.yml | 1 +
fdroid-metadata/de.jeanlucmakiola.calendula.yml | 1 +
6 files changed, 32 insertions(+)
diff --git a/README.md b/README.md
index 6753ecc..ce22130 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,17 @@ 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).
+## π Support
+
+Calendula is free, open source, and built by one person in their own time β
+with no ads, no tracking, and no internet permission. If it makes your days a
+little calmer and you'd like to support its continued development, you can buy
+me a coffee:
+
+[**β ko-fi.com/jeanlucmakiola**](https://ko-fi.com/jeanlucmakiola)
+
+Entirely optional, always appreciated. πΌ
+
## π License
[MIT](LICENSE) β Jean-Luc Makiola, 2026
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
index 5863ebd..21a8f7a 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
@@ -42,11 +42,13 @@ import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ExpandLess
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.Language
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -292,6 +294,7 @@ private fun AboutCard() {
val context = LocalContext.current
val sourceUrl = stringResource(R.string.about_source_url)
val licenseUrl = stringResource(R.string.about_license_url)
+ val supportUrl = stringResource(R.string.about_support_url)
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -350,6 +353,19 @@ private fun AboutCard() {
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))
+ }
}
}
}
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 42e7f52..aa24a30 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -315,6 +315,7 @@
MIT
von Jean-Luc Makiola
Quellcode
+ Entwicklung unterstΓΌtzen
Version %1$s
Calendula-App-Symbol
Problem melden
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c3c6725..1be5841 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -308,6 +308,7 @@
MIT
by Jean-Luc Makiola
Source
+ Support development
Version %1$s
Calendula app icon
Report a problem
@@ -378,6 +379,7 @@
https://gitea.jeanlucmakiola.de/makiolaj/calendula
https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE
+ https://ko-fi.com/jeanlucmakiola
diff --git a/docs/fdroid-official/de.jeanlucmakiola.calendula.yml b/docs/fdroid-official/de.jeanlucmakiola.calendula.yml
index 5588023..45a7a3e 100644
--- a/docs/fdroid-official/de.jeanlucmakiola.calendula.yml
+++ b/docs/fdroid-official/de.jeanlucmakiola.calendula.yml
@@ -21,6 +21,7 @@ AuthorName: Jean-Luc Makiola
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
+Donate: https://ko-fi.com/jeanlucmakiola
AutoName: Calendula
diff --git a/fdroid-metadata/de.jeanlucmakiola.calendula.yml b/fdroid-metadata/de.jeanlucmakiola.calendula.yml
index 01c5526..2f6581e 100644
--- a/fdroid-metadata/de.jeanlucmakiola.calendula.yml
+++ b/fdroid-metadata/de.jeanlucmakiola.calendula.yml
@@ -8,3 +8,4 @@ Categories:
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
+Donate: https://ko-fi.com/jeanlucmakiola
From 2c38912834a7ece6f9082260c23fbae75302071e Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 12:51:12 +0200
Subject: [PATCH 02/21] 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)
---
README.md | 41 +++++++++++++++++++++--------------------
1 file changed, 21 insertions(+), 20 deletions(-)
diff --git a/README.md b/README.md
index ce22130..1b1c48e 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,11 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
+
+
+
+
+
@@ -68,13 +73,20 @@ database, no sync stack reinvented.
## π¦ Install
-Calendula ships through a self-hosted F-Droid repository; every version tag
-is built, signed, and published there automatically.
+### F-Droid (recommended)
-1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo
- Store, β¦).
-2. Add the repository β open this link on your phone, or paste it under
- *Settings β Repositories β Add*:
+Calendula is on the **official [F-Droid](https://f-droid.org) repository** β
+just search for **Calendula** in any F-Droid client, or
+[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
+
+### 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
@@ -84,10 +96,10 @@ is built, signed, and published there automatically.
fingerprint (SHA-256):
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`
-3. Refresh, search for **Calendula**, install. Updates arrive like any
- other F-Droid app.
+2. Refresh, search for **Calendula**, install.
-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
@@ -115,17 +127,6 @@ 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).
-## π Support
-
-Calendula is free, open source, and built by one person in their own time β
-with no ads, no tracking, and no internet permission. If it makes your days a
-little calmer and you'd like to support its continued development, you can buy
-me a coffee:
-
-[**β ko-fi.com/jeanlucmakiola**](https://ko-fi.com/jeanlucmakiola)
-
-Entirely optional, always appreciated. πΌ
-
## π License
[MIT](LICENSE) β Jean-Luc Makiola, 2026
From b7a5f16c84770cd269a2d45eff76d30150e1b1bf Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 12:56:26 +0200
Subject: [PATCH 03/21] 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)
---
README.md | 28 ++++------------------------
docs/BUILDING.md | 24 ++++++++++++++++++++++++
2 files changed, 28 insertions(+), 24 deletions(-)
create mode 100644 docs/BUILDING.md
diff --git a/README.md b/README.md
index 1b1c48e..8eeac68 100644
--- a/README.md
+++ b/README.md
@@ -101,31 +101,11 @@ rebuilds on F-Droid's own schedule. Add it for the freshest builds:
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:
-
-```bash
-./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.1βv2.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).
+- **[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
## π License
diff --git a/docs/BUILDING.md b/docs/BUILDING.md
new file mode 100644
index 0000000..6524e94
--- /dev/null
+++ b/docs/BUILDING.md
@@ -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).
From 00220c5f0e635e3452fa215df9137d2da240c05d Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 13:04:15 +0200
Subject: [PATCH 04/21] 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)
---
README.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 8eeac68..36d4b4d 100644
--- a/README.md
+++ b/README.md
@@ -13,11 +13,12 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
+
+
From f4789c535e13cd88a8d9e8a8d8ced3620cfb364f Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 13:06:03 +0200
Subject: [PATCH 05/21] 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)
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 36d4b4d..c86e52c 100644
--- a/README.md
+++ b/README.md
@@ -16,9 +16,9 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
+
-
+
From 96f4d4dc84ebb998a54f7ea0b0c42414b5d76fb9 Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 13:07:04 +0200
Subject: [PATCH 06/21] docs(readme): restore F-Droid badge to original height
Co-Authored-By: Claude Opus 4.8 (1M context)
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index c86e52c..a6d3eb3 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
+
From 913b49e962609ae3ea29a45660f2bd2a5c3d7ce1 Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 13:08:12 +0200
Subject: [PATCH 07/21] 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)
---
README.md | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index a6d3eb3..1f194b0 100644
--- a/README.md
+++ b/README.md
@@ -15,11 +15,10 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
-
-
-
-
+
+ |
+ |
+
From 8a9e6f0ee8ebece978309e7624fd7b466e40667e Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 13:09:29 +0200
Subject: [PATCH 08/21] docs(readme): stack F-Droid and Ko-fi buttons, drop
bordered table
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
README.md | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 1f194b0..8dc300e 100644
--- a/README.md
+++ b/README.md
@@ -15,10 +15,13 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
- |
- |
-
+
+
+
+
+
+
+
From 376ad5f3944e1ed69a98eb1d77026a9d283db792 Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 13:19:57 +0200
Subject: [PATCH 09/21] docs(readme): put F-Droid and Ko-fi buttons inline at
equal height
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
README.md | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 8dc300e..c86e52c 100644
--- a/README.md
+++ b/README.md
@@ -16,10 +16,8 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
-
-
-
+
+
From dd61e0ed377da3327e5d9fa4065f2487951bca57 Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 13:23:13 +0200
Subject: [PATCH 10/21] 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)
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index c86e52c..e9e2790 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
+
From 575b1c7eb7fe08b140b26940397af700c3d9da39 Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 11:25:55 +0000
Subject: [PATCH 11/21] Update ReadMe.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index e9e2790..c642fb9 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
+
From 0ddbbb11e8438ebb7f27c1ed863fe0588c0f1dae Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 11:28:19 +0000
Subject: [PATCH 12/21] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index c642fb9..430bda4 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
+
From 8a63aa86a779367f91fdebcb6442921891b4c984 Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 11:28:44 +0000
Subject: [PATCH 13/21] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 430bda4..e9e2790 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
+
From 946addbbcdf946c1acbeef1698e9dd8220b6f6c0 Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 13:30:59 +0200
Subject: [PATCH 14/21] 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)
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index e9e2790..994ecd2 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
+
From 43eb5e23e84a4da4d0acae8097fcd5a72e6dab9e Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 13:33:41 +0200
Subject: [PATCH 15/21] docs(readme): enlarge F-Droid badge to height 80
Co-Authored-By: Claude Opus 4.8 (1M context)
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 994ecd2..94808bb 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
+
From cecd0c223498385cd5eda640e7731d5fb8a9cd79 Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 13:34:27 +0200
Subject: [PATCH 16/21] docs(readme): revert F-Droid badge to height 56
Co-Authored-By: Claude Opus 4.8 (1M context)
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 94808bb..994ecd2 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Reads, writes, and reminds β on top of the system calendar, with zero network
-
+
From a1e8c05c7b122d79d47c54825374d693803bfa4a Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 13:39:01 +0200
Subject: [PATCH 17/21] 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)
---
.planning/ROADMAP.md | 154 +++++++++++++++++++++++++++++--------------
1 file changed, 106 insertions(+), 48 deletions(-)
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index e090596..a27a7dc 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -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)**
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)*
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).
**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"
- bucket, owner says high-importance 2026-06-22)* β attendees are already
- *read* (queried, mapped, shown on the detail screen since v0.6) but the
- event form can't *write* them, the last big read-only gap in the event
- model. Add an attendees section to `EventEditScreen` / `EventForm`: add by
- typed email (contact-picker entry can follow later), edit/remove rows, set
- role (required / optional) β writing `CalendarContract.Attendees` rows on
- insert + dirty-checked update, mirroring the reminders-diff pattern.
- **Caveat to resolve first:** writing `Attendees` rows triggers sync-adapter
- *invitation* behavior that differs by backend (Google auto-emails invites;
- DAVx5/CalDAV writes the `ATTENDEE` property and lets the server decide) β
- decide and document what Calendula promises before building. Full sketch in
- "Attendee editing" under Locations & People below.
+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 β 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)
**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,
-full-text search. Pulled in opportunistically, not sequenced.
+**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).
-**Next committed work is Tier 5 #12 β attendee editing** (the last read-only gap
-in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
-(quick-settings tile, now-line, week numbers in month, full-text search).
+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
@@ -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
natural data source for a future widget)
- Jump to date β drawer date picker (un-cut from V1)
-- Current-time "now" line in day/week β standard in every calendar, cheap,
- currently absent. Daily-driver polish.
-- Week numbers in the **month** grid β week view already shows the badge
- (`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
+- ~~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 *(was v3.0)* β promote out of "fill-in": for a daily driver
- with real event history, finding an event is core completeness, not optional.
+- ~~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** (`Events.EVENT_COLOR`, OptionCard picker in the form)
- *(next)* β chosen to follow the in-progress tap-to-create + calendar
- management work: reuses the color-picker component and palette plumbing
- being built for local calendar management, and finishes the create/edit
- theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list
- (`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
+- ~~**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
@@ -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
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` + `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.
@@ -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
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
- (reliability vs simplicity + battery cost).
-2. All-day reminder representation (minutes-before vs absolute time-of-day).
-3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
- a list inside the Notifications sub-page).
+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.
-### Later (round two)
+### Round two
-- Snooze + dismiss actions on the notification (snooze needs an
- exact-alarm / WorkManager decision) β Tier 4 #13.
+- ~~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
@@ -438,8 +495,8 @@ it directly undermines the feature's premise, so it rides in here.
## Platform & launchers
- ~~Home-screen widget~~ **shipped v2.5.0** β agenda + month widgets
-- ~~App shortcuts (launcher long-press β New event)~~ **shipped v2.5.0** β
- optional quick-settings tile still open
+- ~~App shortcuts (launcher long-press β New event)~~ **shipped v2.5.0**
+- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
## 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
in detail yet:
-- **Contact address picker** for the location field via the system picker
- (`ACTION_PICK` on postal addresses) β one-shot, needs no READ_CONTACTS,
- fits the privacy story. Same mechanism later for picking emails.
+- ~~**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
From be6d10d12641716f2aae7d2a23db10519f6b31e0 Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 13:55:10 +0200
Subject: [PATCH 18/21] feat(calendars): add app-side enable/disable for
calendars
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.../data/calendar/CalendarRepositoryImpl.kt | 31 ++++++---
.../calendula/data/prefs/CalendarPrefs.kt | 26 +++++++
.../calendula/ui/calendars/CalendarsScreen.kt | 59 +++++++++++++---
.../ui/calendars/CalendarsViewModel.kt | 30 ++++++++
.../calendula/ui/common/GroupedList.kt | 15 +++-
.../calendula/ui/edit/EventEditViewModel.kt | 16 +++--
.../calendula/ui/filter/FilterViewModel.kt | 11 ++-
.../calendula/ui/imports/ImportViewModel.kt | 18 +++--
app/src/main/res/values/strings.xml | 2 +
.../calendar/CalendarRepositoryImplTest.kt | 68 +++++++++++++++++++
.../calendula/data/prefs/CalendarPrefsTest.kt | 30 ++++++++
11 files changed, 274 insertions(+), 32 deletions(-)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
index c7949ec..bde325a 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
@@ -55,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor(
.reQuery { dataSource.calendars() }
.flowOn(io)
- // Instances are filtered by the app-side hidden-calendar set (M3): an event
- // is dropped whenever the user has hidden its calendar. Re-runs when the
- // provider ticks *or* the hidden set changes β toggling a calendar in the
- // filter sheet updates every view immediately. [calendars] stays unfiltered
- // so the filter sheet can list and re-enable hidden calendars.
+ // Instances are filtered by the app-side hidden βͺ disabled calendar sets
+ // (M3): an event is dropped whenever the user has hidden *or* disabled its
+ // calendar. Re-runs when the provider ticks *or* either set changes β
+ // toggling a calendar in the filter sheet or the calendar manager updates
+ // every view immediately. [calendars] stays unfiltered so those screens can
+ // list and re-enable hidden/disabled calendars.
override fun instances(range: ClosedRange): Flow> =
combine(
ticks
@@ -71,10 +73,17 @@ class CalendarRepositoryImpl @Inject constructor(
)
},
prefs.hiddenCalendarIds,
- ) { instances, hidden ->
- if (hidden.isEmpty()) instances
- else instances.filterNot { it.calendarId in hidden }
- }.flowOn(io)
+ prefs.disabledCalendarIds,
+ ) { instances, hidden, disabled ->
+ val excluded = hidden + disabled
+ 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) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
@@ -82,9 +91,9 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun searchEvents(query: String): List = withContext(io) {
if (query.isBlank()) return@withContext emptyList()
- val hidden = prefs.hiddenCalendarIds.first()
+ val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
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 =
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt
index 8f724e5..bc01927 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt
@@ -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> = store.data.map { prefs ->
+ prefs[DISABLED_IDS_KEY].orEmpty()
+ .split(',')
+ .mapNotNull { it.trim().toLongOrNull() }
+ .toSet()
+ }
+
+ suspend fun setDisabledCalendarIds(ids: Set) {
+ 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
* event form. Null until the first event is created.
@@ -53,6 +78,7 @@ class CalendarPrefs @Inject constructor(
companion object {
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")
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt
index c0f02b1..82fe32b 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt
@@ -31,7 +31,6 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
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.OpenInNew
import androidx.compose.material.icons.filled.Palette
@@ -46,6 +45,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -59,11 +59,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
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.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
@@ -98,6 +101,7 @@ fun CalendarsScreen(
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
+ val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
@@ -134,6 +138,7 @@ fun CalendarsScreen(
CalendarsList(
local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal },
+ disabledIds = disabledIds,
error = error,
onConsumeError = viewModel::consumeError,
backupResult = backupResult,
@@ -142,6 +147,7 @@ fun CalendarsScreen(
onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
+ onSetDisabled = viewModel::setDisabled,
)
}
}
@@ -150,6 +156,7 @@ fun CalendarsScreen(
private fun CalendarsList(
local: List,
synced: List,
+ disabledIds: Set,
error: Boolean,
onConsumeError: () -> Unit,
backupResult: BackupResult?,
@@ -158,6 +165,7 @@ private fun CalendarsList(
onBack: () -> Unit,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
+ onSetDisabled: (Long, Boolean) -> Unit,
) {
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
@@ -206,19 +214,21 @@ private fun CalendarsList(
if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty))
}
+ HintText(stringResource(R.string.calendars_disable_hint))
val localCount = local.size + 1
local.forEachIndexed { index, calendar ->
+ val disabled = calendar.id in disabledIds
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, localCount),
- leading = { CalendarColorChip(calendar.color) },
+ dimmed = disabled,
+ leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
- Icon(
- Icons.Default.Edit,
- contentDescription = stringResource(R.string.calendars_edit_title),
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.size(20.dp),
+ EnableSwitch(
+ calendarName = calendar.displayName,
+ enabled = !disabled,
+ onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
)
},
onClick = { onEdit(calendar) },
@@ -258,10 +268,19 @@ private fun CalendarsList(
.forEach { (account, cals) ->
AccountHeader(account = account, accountType = cals.first().accountType)
cals.forEachIndexed { index, calendar ->
+ val disabled = calendar.id in disabledIds
GroupedRow(
title = calendar.displayName,
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) },
+ )
+ },
)
}
}
@@ -425,6 +444,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). */
@Composable
private fun EditorCard(
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt
index c731d38..17451f7 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
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.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -33,6 +35,7 @@ import javax.inject.Inject
class CalendarsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
+ private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -46,6 +49,20 @@ class CalendarsViewModel @Inject constructor(
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> =
+ prefs.disabledCalendarIds
+ .flowOn(io)
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000L),
+ initialValue = emptySet(),
+ )
+
private val _error = MutableStateFlow(false)
val error: StateFlow = _error.asStateFlow()
@@ -93,6 +110,19 @@ class CalendarsViewModel @Inject constructor(
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) {
viewModelScope.launch {
try {
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt
index 445e38b..d7d489f 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt
@@ -128,7 +128,9 @@ fun CollapsingScaffold(
* 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
* 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)
@Composable
@@ -138,6 +140,7 @@ fun GroupedRow(
modifier: Modifier = Modifier,
summary: String? = null,
selected: Boolean = false,
+ dimmed: Boolean = false,
minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
@@ -169,6 +172,16 @@ fun GroupedRow(
supportingColor = 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 {
ListItemDefaults.colors(containerColor = Color.Transparent)
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
index e878330..dbf9cf1 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
@@ -121,10 +121,18 @@ class EventEditViewModel @Inject constructor(
val allowColorOnUnsupported: Boolean,
)
- /** Writable calendars β the only valid event targets. */
- private val writableCalendars: Flow> = repository.calendars()
- .map { calendars -> calendars.filter { it.canModifyContents } }
- .catch { emit(emptyList()) }
+ /**
+ * Writable calendars β the only valid event targets. Disabled calendars are
+ * excluded, so you can't create into a calendar you've removed from the app;
+ * 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> = 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. */
private val resolvedCalendarId: Flow = combine(
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt
index 639c968..28dd615 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt
@@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor(
combine(
repository.calendars(),
prefs.hiddenCalendarIds,
- ) { calendars, hidden ->
- if (calendars.isEmpty()) {
+ prefs.disabledCalendarIds,
+ ) { 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)
} else {
- FilterUiState.Success(groupByAccount(calendars, hidden))
+ FilterUiState.Success(groupByAccount(enabled, hidden))
}
}
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt
index 63debea..445a25f 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
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.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
@@ -56,6 +57,7 @@ sealed interface ImportUiState {
class ImportViewModel @Inject constructor(
private val repository: CalendarRepository,
private val importer: IcsImporter,
+ private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -79,11 +81,17 @@ class ImportViewModel @Inject constructor(
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
warnings = parsed.warnings,
)
- else -> ImportUiState.Many(
- events = parsed.events,
- warnings = parsed.warnings,
- calendars = repository.calendars().first().filter { it.canModifyContents },
- )
+ else -> {
+ // A disabled calendar is removed from the app, so it can't be
+ // an import target β exclude it alongside the read-only ones.
+ val disabled = prefs.disabledCalendarIds.first()
+ ImportUiState.Many(
+ events = parsed.events,
+ warnings = parsed.warnings,
+ calendars = repository.calendars().first()
+ .filter { it.canModifyContents && it.id !in disabled },
+ )
+ }
}
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0a874c0..6401287 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -317,6 +317,8 @@
Your calendars
No local calendars yet. Create one to keep events on this device only.
Add calendar
+ 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.
+ Show \"%1$s\" in the app
Synced calendars
These come from accounts on your device. Create and edit them in their own app.
Manage
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
index 80c8d28..1fb9abd 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
@@ -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
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt
index a72daf3..a077bae 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt
@@ -50,4 +50,34 @@ class CalendarPrefsTest {
}
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))
+ }
}
From 814daa81518dc20bde305c9258908285d7277d92 Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 14:06:39 +0200
Subject: [PATCH 19/21] docs(changelog): note in-app calendar enable/disable
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 207b925..64fa6ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,19 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.10.0] β Unreleased
+
+### 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.
+
## [2.9.0] β 2026-06-25
### Added
From 26e538a9ba944dc3b2cd8725242a958996f079ec Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 14:07:40 +0200
Subject: [PATCH 20/21] docs(changelog): note Ko-fi support link
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 207b925..aa27ebb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.10.0] β Unreleased
+
+### Added
+- 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
### Added
From 54b059e744ee29d06be5fe18fd054c8db53c25c4 Mon Sep 17 00:00:00 2001
From: Jean-Luc Makiola
Date: Thu, 25 Jun 2026 14:31:24 +0200
Subject: [PATCH 21/21] chore(release): prepare v2.10.0
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 2 +-
app/build.gradle.kts | 4 ++--
.../metadata/android/en-US/changelogs/21000.txt | 14 ++++++++++++++
3 files changed, 17 insertions(+), 3 deletions(-)
create mode 100644 fastlane/metadata/android/en-US/changelogs/21000.txt
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1b313b..1e9ac45 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [2.10.0] β Unreleased
+## [2.10.0] β 2026-06-25
### Added
- Turn a calendar off across the whole app. Settings β Calendars now has a
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3a1f441..ad97564 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
- versionCode = 20900
- versionName = "2.9.0"
+ versionCode = 21000
+ versionName = "2.10.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/fastlane/metadata/android/en-US/changelogs/21000.txt b/fastlane/metadata/android/en-US/changelogs/21000.txt
new file mode 100644
index 0000000..e2ed120
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/21000.txt
@@ -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.