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 -- 2.49.1 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 Kotlin + Compose Material 3 Expressive MIT License +Support development on Ko-fi +

+ +

+Get it on F-Droid

@@ -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 -- 2.49.1 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). -- 2.49.1 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 Kotlin + Compose Material 3 Expressive MIT License -Support development on Ko-fi

Get it on F-Droid +  +Support me on Ko-fi

-- 2.49.1 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

-Get it on F-Droid +Get it on F-Droid   -Support me on Ko-fi +Support me on Ko-fi

-- 2.49.1 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

-Get it on F-Droid +Get it on F-Droid   Support me on Ko-fi

-- 2.49.1 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 MIT License

-

-Get it on F-Droid -  -Support me on Ko-fi -

+ + + +
Get it on F-DroidSupport me on Ko-fi

Week view  -- 2.49.1 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 MIT License

- - - -
Get it on F-DroidSupport me on Ko-fi
+

+Get it on F-Droid +

+ +

+Support me on Ko-fi +

Week view  -- 2.49.1 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

-Get it on F-Droid -

- -

+Get it on F-DroidSupport me on Ko-fi

-- 2.49.1 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

Get it on F-Droid   -Support me on Ko-fi +Support me on Ko-fi

-- 2.49.1 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

-Get it on F-Droid +Get it on F-Droid   Support me on Ko-fi

-- 2.49.1 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

-Get it on F-Droid +Get it on F-Droid   Support me on Ko-fi

-- 2.49.1 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

-Get it on F-Droid +Get it on F-Droid   Support me on Ko-fi

-- 2.49.1 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

Get it on F-Droid   -Support me on Ko-fi +Support me on Ko-fi

-- 2.49.1 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

-Get it on F-Droid +Get it on F-Droid   Support me on Ko-fi

-- 2.49.1 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

-Get it on F-Droid +Get it on F-Droid   Support me on Ko-fi

-- 2.49.1 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 -- 2.49.1 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)) + } } -- 2.49.1 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 -- 2.49.1 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 -- 2.49.1 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. -- 2.49.1