From c6e83fc071f5ca6355976d379a88396377ac7a4f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 25 Jul 2026 20:09:22 +0000 Subject: [PATCH 1/6] chore(renovate): show real release age and fetch changelogs (!98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps in the Renovate setup, all about having enough in front of you at review time. ### Age reads UNKNOWN `config:recommended` already brings in `mergeConfidence:age-confidence-badges`, so the Age column existed — it just rendered grey. Fetching the badge SVGs from `developer.mend.io` directly shows why: Mend's index covers Maven Central but has nothing for Google's Maven repo, so every androidx/compose coordinate is blank. No token changes it; the JSON API behind the badges answers 401 for everyone. | coordinate | registry | age badge | | --- | --- | --- | | `androidx.compose:compose-bom` | Google Maven | UNKNOWN | | `androidx.core:core-ktx` | Google Maven | UNKNOWN | | `com.google.truth:truth` | Maven Central | 2y | | `org.jetbrains.kotlin:kotlin-stdlib` | Maven Central | 1y (confidence high, passing 99%) | Age is now computed from `newVersionAgeInDays`, which Renovate derives itself to evaluate `minimumReleaseAge` — Google Maven serves `last-modified` on its POMs, so it's populated where Mend is blank, and the number agrees with the tiers it's read against. Mend keeps the Confidence column, which still resolves for the Maven Central half (Kotlin, Gradle, AGP, the test stack). ### Empty release notes We run against Gitea, but the packages are *released* on GitHub, so changelog lookups were going out unauthenticated against a 60/h limit. `RENOVATE_GITHUB_COM_TOKEN` lifts that. **Needs a secret before it does anything:** a github.com PAT with **no scopes ticked**, added as repo secret `GITHUB_COM_TOKEN`. Until then the var resolves empty, which is exactly today's behaviour. ### Nothing expressed how settled a release is Cooling-off scaled by blast radius: 30 days major, 20 minor, 10 patch/digest. Deliberately advisory. Renovate's default `internalChecksFilter: strict` would suppress the PR outright until the version aged in; `none` opens it at the highest version immediately, so merging ahead of the window stays a decision rather than a wait. A too-young release still gets a yellow `renovate/stability-days` check — `setStability` computes that from `minimumReleaseAge` + `releaseTimestamp` independently of the filter — and with `automerge: false` nothing acts on it. ### Notes Validated with `renovate-config-validator` against the pinned 43.232.0. Config is read from the default branch, so the open bump PRs keep their current tables until the next run after this merges. Reviewed-on: https://gitea.jeanlucmakiola.de/makiolaj/calendula/pulls/98 --- .gitea/workflows/renovate.yml | 5 ++++ renovate.json5 | 52 +++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml index 5b5ebda..4a39197 100644 --- a/.gitea/workflows/renovate.yml +++ b/.gitea/workflows/renovate.yml @@ -39,4 +39,9 @@ jobs: RENOVATE_REPOSITORIES: '["makiolaj/calendula"]' # Commits/PRs authored as the bot, not a real maintainer. RENOVATE_GIT_AUTHOR: 'Renovate Bot ' + # Read-only github.com PAT (no scopes needed). We run on Gitea, but + # nearly every dependency is *released* on GitHub — without this, + # changelog/release-note lookups hit the 60/h anonymous rate limit + # and PRs arrive with an empty "Release Notes" section. + RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }} LOG_LEVEL: info diff --git a/renovate.json5 b/renovate.json5 index 103bd3d..efc41ff 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -7,6 +7,23 @@ ":semanticCommits", ], + // `config:recommended` brings in mergeConfidence:age-confidence-badges, whose + // Age column is a Mend badge. Mend's Merge Confidence index only covers Maven + // Central: org.jetbrains.kotlin, junit, truth, turbine et al resolve, but + // every androidx/compose artifact lives on Google's Maven repo and comes back + // as a grey UNKNOWN — i.e. most of this project. Renovate already knows the + // real answer, since it derives release timestamps itself for the + // minimumReleaseAge rules below (Google Maven serves `last-modified` on its + // POMs), so take the age from there and leave Mend to the Confidence column, + // which still carries signal for the Maven Central half. + prBodyDefinitions: { + Age: "{{#if releaseTimestamp}}{{{newVersionAgeInDays}}} d{{else}}unknown{{/if}}", + }, + // Default heading links to the Merge Confidence docs; this column is ours now. + prBodyHeadingDefinitions: { + Age: "Age", + }, + // No automerge: a dependency bump goes through the same review (and, for // anything touching the build, the same on-device check) as a feature // before it can ride a release — see docs/RELEASING.md and the @@ -15,6 +32,16 @@ // One reviewable surface; the dashboard issue lists everything pending. dependencyDashboard: true, + + // The cooling-off periods below are advisory, not a gate: "none" turns off + // filtering on the minimumReleaseAge check, so the PR is opened at the + // highest version straight away and merging early stays a judgement call. + // (Renovate's default here is "strict", which suppresses the PR entirely + // until the release has aged in.) A still-young release carries a pending + // `renovate/stability-days` check so it's visible which side of the line + // it's on; with automerge off, nothing acts on that check by itself. + internalChecksFilter: "none", + labels: ["dependencies"], prConcurrentLimit: 5, prHourlyLimit: 0, @@ -30,6 +57,21 @@ }, packageRules: [ + // Cooling-off period, scaled by blast radius: how long a release should + // have been out (and un-yanked, un-hotfixed) before it's considered + // settled. Advisory only — see `internalChecksFilter` above. + { + matchUpdateTypes: ["major"], + minimumReleaseAge: "30 days", + }, + { + matchUpdateTypes: ["minor"], + minimumReleaseAge: "20 days", + }, + { + matchUpdateTypes: ["patch", "digest", "pin", "rollback"], + minimumReleaseAge: "10 days", + }, // material3 is deliberately pinned to the 1.5 *alpha* line for the // Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train // but keep it in its own PR, reviewed in isolation; revisit the pin when @@ -52,5 +94,15 @@ ], groupName: "test dependencies", }, + // Last word on the PR table. The merge-confidence preset sets prBodyColumns + // from inside a packageRule of its own, and only for the datasources Mend + // supports — so a plain top-level prBodyColumns would lose to it for maven + // deps, and the Gradle wrapper / Actions / container bumps would keep the + // default columns and show no age at all. A rule declared after it wins, + // and gives every PR the same table. + { + matchPackageNames: ["*"], + prBodyColumns: ["Package", "Type", "Change", "Age", "Confidence"], + }, ], } From 314236ac0cd0ce09b31a1ec2b3b15effd0615f54 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 25 Jul 2026 20:31:33 +0000 Subject: [PATCH 2/6] fix(renovate): use a flexible internal-checks filter so ages resolve (!100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Age came back `unknown` for every dependency after #98. That's the `internalChecksFilter: "none"` from that PR doing what it says on the tin — and rather more. ### Why `lib/workers/repository/process/lookup/filter-checks.ts` short-circuits on `none`: ```js if (internalChecksFilter === 'none') { release = sortedReleases.pop(); // returns here } else { for (let candidateRelease of sortedReleases.reverse()) { ... const updatedCandidateRelease = await postprocessRelease(...) ``` `postprocessRelease` is the only caller that fetches a Maven artifact's `Last-Modified` header (`lib/modules/datasource/maven/index.ts:222`), so skipping the loop leaves `releaseTimestamp` unset. That empties the Age column — but it also silently voids `minimumReleaseAge` and the `renovate/stability-days` check, since both compare against that same timestamp. The cooling-off tiers were decorative. ### Fix `flexible` keeps the intent — still prefers a version that has cleared its window, but opens the PR at the newest candidate when none has — while running the loop that populates the timestamps. Verified with a local `--platform=local --dry-run=full`: | dependency | new version | age | | --- | --- | --- | | `androidx.compose:compose-bom` | 2026.06.01 | 24 d | | `androidx.compose.material3:material3` | 1.5.0-alpha24 | 10 d | | `androidx.work:work-runtime-ktx` | 2.11.2 | 122 d | | `com.android.application` (AGP) | 9.3.1 | 2 d | | `gradle` | 9.6.1 | 29 d | Only `ghcr.io/renovatebot/renovate` stays unknown, because the docker registry serves no timestamps at all — the run marks all 176 of its tags pending for that reason, and `flexible` is what still lets that PR through. `Pending` rejoins the table: under a flexible filter it names the newer version being held back, rather than leaving it invisible. ### Also Migrates the Gitea workflow manager off the deprecated `fileMatch`. Renovate's config migration was rewriting it to `managerFilePatterns` (delimited regex) on every run and warning about it; the dry run confirms the manager still picks up `.gitea/workflows` afterwards. Validated with `renovate-config-validator` against the pinned 43.232.0. Reviewed-on: https://gitea.jeanlucmakiola.de/makiolaj/calendula/pulls/100 --- renovate.json5 | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/renovate.json5 b/renovate.json5 index efc41ff..378bf07 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -33,14 +33,20 @@ // One reviewable surface; the dashboard issue lists everything pending. dependencyDashboard: true, - // The cooling-off periods below are advisory, not a gate: "none" turns off - // filtering on the minimumReleaseAge check, so the PR is opened at the - // highest version straight away and merging early stays a judgement call. - // (Renovate's default here is "strict", which suppresses the PR entirely - // until the release has aged in.) A still-young release carries a pending + // The cooling-off periods below are advisory, not a gate. "flexible" still + // prefers a version that has cleared its window, but when every candidate is + // too young it opens the PR at the newest one anyway, so merging early stays + // a judgement call. (The default, "strict", would suppress the PR entirely + // until a release aged in.) A still-young branch carries a yellow // `renovate/stability-days` check so it's visible which side of the line // it's on; with automerge off, nothing acts on that check by itself. - internalChecksFilter: "none", + // + // NOT "none": that short-circuits the candidate loop in filter-checks.ts, and + // that loop is what calls postprocessRelease — the only thing that fetches a + // Maven artifact's Last-Modified header. Skipping it leaves releaseTimestamp + // unset, which empties the Age column and quietly makes minimumReleaseAge and + // the stability check no-ops, since both need that timestamp to compare. + internalChecksFilter: "flexible", labels: ["dependencies"], prConcurrentLimit: 5, @@ -52,8 +58,10 @@ // Gitea Actions workflows live under .gitea/workflows, not .github — extend // the github-actions manager (same syntax) to watch them too. + // `fileMatch` is deprecated; the replacement takes the regex delimited, and + // Renovate's config migration was already rewriting this on every run. "github-actions": { - fileMatch: ["^\\.gitea/workflows/[^/]+\\.ya?ml$"], + managerFilePatterns: ["/^\\.gitea/workflows/[^/]+\\.ya?ml$/"], }, packageRules: [ @@ -100,9 +108,12 @@ // deps, and the Gradle wrapper / Actions / container bumps would keep the // default columns and show no age at all. A rule declared after it wins, // and gives every PR the same table. + // "Pending" earns its place under a flexible filter: when the bump lands on + // a version that has cleared its window but a newer one hasn't, that newer + // version is named here rather than silently withheld. { matchPackageNames: ["*"], - prBodyColumns: ["Package", "Type", "Change", "Age", "Confidence"], + prBodyColumns: ["Package", "Type", "Change", "Age", "Pending", "Confidence"], }, ], } From ac0c43f9308eff02740f4d2daac224c91d86feb6 Mon Sep 17 00:00:00 2001 From: Weblate Date: Sun, 26 Jul 2026 08:26:54 +0000 Subject: [PATCH 3/6] Translations update from Weblate (!99) Translations update from [Weblate](https://weblate.dev.jeanlucmakiola.de) for [Calendula/Strings](https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/). Current translation status: ![Weblate translation status](https://weblate.dev.jeanlucmakiola.de/widget/calendula/strings/horizontal-auto.svg) Co-authored-by: Bazyli Cyran Co-authored-by: Sup99999 Reviewed-on: https://gitea.jeanlucmakiola.de/makiolaj/calendula/pulls/99 Co-authored-by: Weblate Co-committed-by: Weblate --- app/src/main/res/values-ar/strings.xml | 72 ++++++++++++++++++++++---- app/src/main/res/values-pl/strings.xml | 30 ++++++++++- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index ce0c971..49c17a2 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -53,11 +53,11 @@ تعذّر مشاركة هذا الحدث. حذف الحدث؟ الحدث أُزيل من تقويمك ومن كل جهاز تتم مزامنته معه. - حذف الحدث المتكرر + حذف الحدث المتكرّر فقط هذا الحدث هذا وجميع الأحداث اللاحقة جميع الأحداث في السلسلة - تعديل الحدث المتكرر + تعديل الحدث المتكرّر تعذّر حذف الحدث Calendula يحتاج إلى صلاحية للكتابة لحذف الأحداث إلغاء @@ -108,7 +108,7 @@ الحدث يبقى كما هو الآن هل تريد تطبيق تذكيرك الافتراضي؟ هذا الحدث حُذف في هذه الأثناء، على سبيل المثال على جهاز آخر. لم يعد من الممكن حفظ تغييراتك. - المزيد من الخيارات + المزيد من الخانات علني الافتراضي مشغول @@ -153,7 +153,7 @@ %1$s حتى %2$s %1$s، %2$d مرّات طَبِّق الافتراضي - حدث مُتكرر + حدث متكرّر %1$s في %2$s تم القُبول @@ -166,7 +166,7 @@ ملغي متاح خاص - سري + سرّي المُنظِّم اختياري التذكير الافتراضي @@ -180,7 +180,7 @@ Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة. كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا. هل تستخدم تطبيق تقويم ثانٍ؟ - إذا كان تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا. + إذا تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا. يمكنك تغييره في أي وقت شَغِّل التذكيرات ليس الآن @@ -200,7 +200,7 @@ البحث عن الأحداث مسح لا مزيد من الأحداث اليوم - لا توجد أحداث مطابقة ”%1$s“. + لا أحداث مطابقة ”%1$s“. ابحث عن أحداثك بالعنوان، الموقع أو الملاحظات. الرجوع البحث @@ -217,7 +217,7 @@ رجوع المظهر اللون الديناميكي - يتطلب Android 12 أو أحدث + يتطلب أندرويد ١٢ أو أحدث طريقة العرض الافتراضية ألوان تقويم ناعمة خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم. @@ -227,7 +227,7 @@ اختر ملفًا… خط مخصص تعذّر قراءة هذا الملف كخط - يبدأ الأسبوع في + الأسبوع يبدأ في تلقائي تلقائي تنسيق الوقت @@ -243,4 +243,58 @@ إظهار إخفاء جدول + المنطقة الزمنية للجهاز + البحث عن المناطق الزمنية + جميع المناطق الزمنية + لا منطقة زمنية تطابق \"%1$s\" + %1$s توقيتك + الأحدث + أدخل رقمًا من ١ إلى ٩٩٩ + + (%d) لا دقائق + (%d) دقيقة واحده + %d دقيقتان + %d دقائق + %d دقيقة + %d دقيقة + + + (%d) لا ساعات + (%d) ساعة واحده + %d ساعتان + %d ساعات + %d ساعة + %d ساعة + + + (%d) لا أيام + (%d) يوم واحد + %d يومان + %d أيام + %d يوم + %d يوم + + + (%d) لا أسابيع + (%d) أسبوع واحد + %d أسبوعان + %d أسابيع + %d أسبوع + %d أسبوع + + يبدأ %1$s + ينتهي %1$s + الانتقال إلى اليوم + تحديث + اسم التطبيق + تعتيم الأحداث المكتملة + تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع + زر اليوم في شريط الأدوات + إظهار زر الانتقال إلى اليوم في شريط الأدوات بدلاً من زر عائم + اعرض Calendula كـ \"Calendar\" في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل. + تعتيم + الأحداث السابقة + مدى الجدول + مخصص… + أيام diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 57320a3..e03665b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -354,7 +354,7 @@ Pomóż w tłumaczeniu Dodaj lub ulepsz tłumaczenie w Weblate Motyw, domyślny widok, pierwszy dzień tygodnia - Kolejność przycisku szybkiego przełączania oraz menu + Układ widoku miesiąca, przycisk szybkiego przełączania, kolejność menu Domyślne pola dla nowych wydarzeń Przypomnienia o wydarzeniach Urodziny i rocznice kontaktów @@ -517,4 +517,32 @@ Zachowaj dzisiejszy dzień na górze agendy i widżetu, nawet gdy nie ma już na dziś żadnych zadań. Urodziny Rocznice + Strefa czasowa urządzenia + Podąża za Twoją lokalizacją + Znajdź strefę czasową + Ostatnie + Wszystkie strefy czasowe + Żadna strefa czasowa nie pasuje do „%1$s” + %1$s Twojego czasu + Wpisz liczbę od 1 do 999 + Początek o %1$s + Koniec o %1$s + Dzisiaj + Przycisk „Dzisiaj” na pasku narzędzi + Pokaż przycisk skoku do dzisiaj na pasku narzędzi zamiast przycisku pływającego + Nazwa aplikacji + Wyświetlaj Calendula jako „Kalendarz” w menu aplikacji. Zmieni się tylko nazwa w menu; po przełączeniu ikona może pojawić się w innym miejscu. + Widok miesiąca + Styl widoku miesiąca + Strony + Jeden miesiąc wypełnia ekran. Przesuń w lewo lub w prawo, aby zmienić miesiąc. + Przewijanie miesięcy + Każdy miesiąc znajduje się pod własnym nagłówkiem, oddzielony od następnego niewielkim odstępem. + Tygodnie bez przerw + Tygodnie są ułożone jeden po drugim, każdy miesiąc płynnie łączy się z następnym, bez odstępu pomiędzy. + Podzielony + Zwięzła siatka wyróżnia dni z wydarzeniami a lista dla dnia wybranego dotknięciem wyświetla się poniżej. + Brak planów + Pokaż cały miesiąc + Pokaż wydarzenia dnia From 8e2109d07320ee6d0e3ff16a24d887a44a9360dc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 26 Jul 2026 17:17:02 +0000 Subject: [PATCH 4/6] feat(calendars): explain a calendar's state, and why one is missing from the picker (#76, #78) (!101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #97, on the same release line. After the visibility fix a calendar can be absent from the pickers for three different reasons the app knows and never said; this closes that gap. **Settings → Calendars names the state** (#76, #78): read-only calendars are marked `Read-only`; ones whose account isn't syncing events to this device are marked `Not synced`, sorted to the bottom of their account, dimmed, and left **without a switch** — visibility can't reveal events that aren't on the device, so the control did nothing. **Both pickers end in a "Missing a calendar?" row** (#76) opening the calendar manager, where those labels then say which reason applies. The manager overlay moved to the end of the host's overlay stack so it covers every surface that can open it (Settings, both event forms, the import picker). Review notes: - **Supporting text, not chips.** A row can carry several states at once next to a live switch; M3 supporting text composes there, static badges don't (and a non-interactive chip reads as a broken button). - **`sync_events = 0` counts as "not synced" for account-backed calendars only.** Nothing syncs a device-local calendar by definition, and another app's local calendar can hold real events at 0 — the unsoundness that made the first #75 migration guard wrong. Covered by a test. - **Excluded calendars stay out of the pickers** rather than being listed unpickable — a handful of read-only subscriptions would crowd out the ones you can actually choose. 541 JVM tests green (6 new), lint clean, check_translations clean. **On-device review owed**; the three new string keys need the Weblate backfill. Reviewed-on: https://gitea.jeanlucmakiola.de/makiolaj/calendula/pulls/101 --- CHANGELOG.md | 24 ++++ .../calendar/CalendarVisibilityReconciler.kt | 8 ++ .../calendula/domain/CalendarRowState.kt | 76 ++++++++++++ .../calendula/ui/CalendarHost.kt | 36 ++++-- .../calendula/ui/calendars/CalendarsScreen.kt | 110 +++++++++++++++--- .../ui/calendars/CalendarsViewModel.kt | 27 +++++ .../ui/common/CalendarPickerGroups.kt | 26 +++++ .../calendula/ui/edit/EventEditScreen.kt | 33 ++++++ .../calendula/ui/edit/EventEditViewModel.kt | 14 +-- .../calendula/ui/imports/ImportScreen.kt | 39 ++++++- .../calendula/ui/imports/ImportViewModel.kt | 13 +-- app/src/main/res/values/strings.xml | 7 ++ .../calendula/domain/CalendarRowStateTest.kt | 110 ++++++++++++++++++ .../ui/edit/EventEditViewModelTest.kt | 28 ++++- 14 files changed, 498 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 543bf84..17978a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Settings → Calendars now says what is different about a calendar instead of + leaving you to guess. Ones you can only view — a subscribed calendar, a + calendar shared with you read-only — are marked **Read-only** ([#76]). +- Calendars your device isn't syncing are marked **Not synced**, moved to the + bottom of their account and left without a switch. None of their events are on + the device, so the switch they used to have could not have shown you anything + — the calendar simply looked broken. They are no longer offered when you pick + a calendar for a new or an imported event either: an event saved there would + never reach the account. Whether an account syncs a calendar stays that + account's own app's decision ([#78]). +- The birthday and anniversary calendars Calendula fills from your contacts are + marked **Filled from your contacts**, which is why they can't be picked for a + new event: anything you put there would be removed again on the next sync. + Deleting one is held back while special dates are switched on — Calendula + would simply create it again — and the calendar's editor says so; turn the + feature off under Settings → Special dates and the delete works as usual + ([#76]). +- The calendar picker in the event form and in the .ics import screen now ends + with a **"Missing a calendar?"** row that opens Settings → Calendars, where + those marks then explain why a calendar isn't offered ([#76]). + ### Fixed - Reminders now arrive for every calendar you have switched on. A calendar that was hidden at system level — switched off in another calendar app, or never @@ -1135,3 +1157,5 @@ automatically, with zero telemetry and no internet permission. [#44]: https://codeberg.org/jlmakiola/calendula/issues/44 [#70]: https://codeberg.org/jlmakiola/calendula/issues/70 [#75]: https://codeberg.org/jlmakiola/calendula/issues/75 +[#76]: https://codeberg.org/jlmakiola/calendula/issues/76 +[#78]: https://codeberg.org/jlmakiola/calendula/issues/78 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarVisibilityReconciler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarVisibilityReconciler.kt index bca56dd..b79c039 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarVisibilityReconciler.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarVisibilityReconciler.kt @@ -67,6 +67,14 @@ class CalendarVisibilityReconciler @Inject constructor( // drain and nothing left to decide, so don't pay for the query. if (pending.isEmpty() && noticeSettled) return@withContext val calendars = dataSource.calendars() + // An empty read means "couldn't read", not "no calendars": the data + // source turns a null cursor — a provider momentarily unavailable — + // into an empty list. Both decisions below are one-way, so taking + // that reading as the truth would drop the whole pending set without + // ever writing VISIBLE = 0 (switching the user's calendars back on, + // events and reminders with them) and settle the notice as "nothing + // to explain". Leave both to the next run. + if (calendars.isEmpty()) return@withContext settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending)) if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) { return@withContext diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt new file mode 100644 index 0000000..0272370 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.calendula.domain + +/** + * The ways a calendar can behave unlike a plain, writable one — each of them a + * reason it is missing from the event and import pickers, and each of them + * something the app knows and used to keep to itself (#76). + */ +enum class CalendarStateLabel { + /** + * A special-dates mirror the app fills from contacts. Writable and visible, + * yet no event target: anything authored here is deleted by the next sync, + * which is why it is the one exclusion with nothing else to give it away. + */ + MANAGED, + + /** Contents can't be modified: a WebCal subscription, a read-only share. */ + READ_ONLY, + + /** The account holds the events, but this device isn't syncing them down. */ + NOT_SYNCED, +} + +/** + * Whether the account this calendar belongs to keeps its events off the device + * (`Calendars.SYNC_EVENTS = 0`) — an "empty by construction" calendar: the rows + * simply aren't here, so nothing can display them and no reminder can fire. + * + * Device-local calendars are excluded deliberately. Nothing syncs them by + * definition, so the flag says nothing about them, and a local calendar from + * another app can hold real events at `sync_events = 0` — the same unsoundness + * that made the #75 migration guard wrong. + */ +val CalendarSource.isNotSynced: Boolean + get() = !syncsEvents && !isLocal + +/** + * Whether a visibility switch on this calendar can change anything the user + * would see. It can't for a non-syncing one: there are no events on the device + * to reveal, so the switch would be a control that does nothing. + */ +val CalendarSource.hasVisibilitySwitch: Boolean + get() = !isNotSynced + +/** + * Whether this calendar can be offered as a target for a new or imported event. + * The one predicate behind both pickers, so the states [CalendarStateLabel] + * names on a manager row are exactly the states that keep a calendar out of + * them (#76): + * + * - read-only has nowhere to write; + * - switched off would hide the event the moment it was saved; + * - a managed mirror has the next contact sync delete it; + * - a non-syncing one never carries the event up to the account, and + * `CalendarProvider2` wipes the calendar's rows outright when the + * subscription is switched back on — a saved event is a dead end either way. + * + * This is the test for *targets*. An event already living in an excluded + * calendar keeps it; the editor adds that calendar back to its picker. + */ +val CalendarSource.isEventTarget: Boolean + get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced + +/** Every state worth naming on this calendar's row, in reading order. */ +fun CalendarSource.stateLabels(): List = buildList { + if (isManaged) add(CalendarStateLabel.MANAGED) + if (!canModifyContents) add(CalendarStateLabel.READ_ONLY) + if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED) +} + +/** + * Calendar-manager order within one group: the ones you can actually act on + * first, the non-syncing ones after them. Stable otherwise, so the provider's + * display-name ordering survives. + */ +fun List.orderedForManager(): List = + sortedBy { it.isNotSynced } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index b830f74..4000f7c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -415,6 +415,7 @@ fun CalendarHost( initialStartMinutes = createStartMinutes ?: heldCreateMinutes, onClose = { createDateIso = null }, onSaved = { createDateIso = null }, + onManageCalendars = { showCalendars = true }, ) } } @@ -434,6 +435,7 @@ fun CalendarHost( editKey = null detailKey = null }, + onManageCalendars = { showCalendars = true }, ) } } @@ -450,18 +452,6 @@ fun CalendarHost( ) } - // Calendar manager — slides over Settings. - AnimatedVisibility( - visible = showCalendars, - enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), - ) { - CalendarsScreen( - onBack = { showCalendars = false }, - onImport = { importUri = it; importForceMany = true }, - ) - } - // Import flow for an opened/received .ics file. A single event routes // into the create form (prefilled, for review); many open the picker. importUri?.let { uri -> @@ -469,6 +459,7 @@ fun CalendarHost( uri = uri, forceMany = importForceMany, onClose = { importUri = null }, + onManageCalendars = { showCalendars = true }, onOpenSingle = { form -> importUri = null importFormSource = ImportSource.File @@ -483,6 +474,27 @@ fun CalendarHost( initialFormSource = importFormSource, onClose = { importForm = null }, onSaved = { importForm = null }, + onManageCalendars = { showCalendars = true }, + ) + } + + // Calendar manager — declared last so it covers every overlay that can + // open it: Settings, both event forms, and the .ics import picker (#76). + // Coming back from it leaves the caller exactly as it was, with the + // calendar list already refreshed by the provider's notification. + AnimatedVisibility( + visible = showCalendars, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + CalendarsScreen( + onBack = { showCalendars = false }, + // The manager opens the import too (restore from backup), and + // that way round it has to step aside: declared above the import + // overlays, it would otherwise cover the screen it just asked + // for. Closing it hands the user back to whatever opened the + // manager once the import is done. + onImport = { importUri = it; importForceMany = true; showCalendars = false }, ) } } 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 9ed5226..b940c03 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 @@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.FileUpload import androidx.compose.material.icons.filled.MoreVert @@ -91,6 +92,12 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.CalendarStateLabel +import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch +import de.jeanlucmakiola.calendula.domain.isEventTarget +import de.jeanlucmakiola.calendula.domain.isNotSynced +import de.jeanlucmakiola.calendula.domain.orderedForManager +import de.jeanlucmakiola.calendula.domain.stateLabels import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors @@ -139,6 +146,7 @@ fun CalendarsScreen( viewModel: CalendarsViewModel = hiltViewModel(), ) { val calendars by viewModel.calendars.collectAsStateWithLifecycle() + val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() @@ -157,6 +165,7 @@ fun CalendarsScreen( initialName = editing?.displayName.orEmpty(), initialColor = editing?.color ?: CalendarColorPalette.all.first(), initialDescription = editing?.description.orEmpty(), + deleteLocked = editing != null && editing.id in deleteLockedIds, onSave = { name, color, description -> val id = editorId if (id == null || id == NEW_CALENDAR_ID) { @@ -305,7 +314,7 @@ private fun CalendarsList( val disabled = !calendar.isVisibleInSystem GroupedRow( title = calendar.displayName, - summary = calendar.description, + summary = calendarRowSummary(calendar), position = if (index == local.lastIndex) Position.Bottom else Position.Middle, container = MaterialTheme.colorScheme.surfaceContainerHighest, dimmed = disabled, @@ -327,9 +336,9 @@ private fun CalendarsList( // safety net. Offered only when there is something exportable: the user's // own local calendars (managed special-dates mirrors don't count). val exportable = local.filter { it.canModifyContents && !it.isManaged } - // Restore/import can target any writable, non-managed calendar (local or - // synced), so its availability is broader than export's. - val canImport = (local + synced).any { it.canModifyContents && !it.isManaged } + // Restore/import can target any calendar the import picker would offer + // (local or synced), so its availability is broader than export's. + val canImport = (local + synced).any { it.isEventTarget } if (exportable.isNotEmpty()) { Spacer(Modifier.height(16.dp)) SectionHeader(stringResource(R.string.calendars_backup_header)) @@ -412,7 +421,11 @@ private fun CalendarsList( .forEach { (account, cals) -> val expanded = account !in collapsedAccounts val accountType = cals.first().accountType - val accountDisabled = cals.none { it.isVisibleInSystem } + // A non-syncing calendar has no switch, so it neither counts + // towards "the whole account is off" nor moves with toggle-all. + val switchable = cals.filter { it.hasVisibilitySwitch } + val accountDisabled = switchable.isNotEmpty() && + switchable.none { it.isVisibleInSystem } Spacer(Modifier.height(16.dp)) CalendarGroup( title = account, @@ -432,24 +445,36 @@ private fun CalendarsList( collapsedAccounts - account } }, - showToggleAll = true, - allEnabled = cals.all { it.isVisibleInSystem }, - onToggleAll = { enabled -> onSetAccountVisible(cals.map { it.id }, enabled) }, + showToggleAll = switchable.isNotEmpty(), + allEnabled = switchable.all { it.isVisibleInSystem }, + onToggleAll = { enabled -> + onSetAccountVisible(switchable.map { it.id }, enabled) + }, ) { - cals.forEachIndexed { index, calendar -> - val disabled = !calendar.isVisibleInSystem + // Calendars you can act on first; the ones this device isn't + // syncing sit at the bottom, dimmed and switchless. + val ordered = cals.orderedForManager() + ordered.forEachIndexed { index, calendar -> + val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced GroupedRow( title = calendar.displayName, - position = if (index == cals.lastIndex) Position.Bottom else Position.Middle, + summary = calendarRowSummary(calendar), + position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle, container = MaterialTheme.colorScheme.surfaceContainerHighest, dimmed = disabled, leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, - trailing = { - EnableSwitch( - calendarName = calendar.displayName, - enabled = !disabled, - onToggle = { enabled -> onSetVisible(calendar.id, enabled) }, - ) + trailing = if (calendar.hasVisibilitySwitch) { + { + EnableSwitch( + calendarName = calendar.displayName, + enabled = calendar.isVisibleInSystem, + onToggle = { enabled -> + onSetVisible(calendar.id, enabled) + }, + ) + } + } else { + null }, ) } @@ -557,6 +582,7 @@ private fun CalendarEditor( onSave: (name: String, color: Int, description: String?) -> Unit, onDelete: () -> Unit, onClose: () -> Unit, + deleteLocked: Boolean = false, ) { var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) } var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) } @@ -590,11 +616,23 @@ private fun CalendarEditor( }, actions = { if (!isNew) { - IconButton(onClick = { confirmDelete = true }) { + // Kept in place while the special-dates sync owns this + // calendar, rather than hidden: the button is where you + // expect it, disabled, with the card below saying why — + // and it comes back to life the moment the feature is + // off, when the delete would actually stick. + IconButton( + onClick = { confirmDelete = true }, + enabled = !deleteLocked, + ) { Icon( Icons.Default.Delete, contentDescription = stringResource(R.string.event_detail_delete), - tint = MaterialTheme.colorScheme.error, + tint = if (deleteLocked) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } else { + MaterialTheme.colorScheme.error + }, ) } } @@ -623,6 +661,19 @@ private fun CalendarEditor( .padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (deleteLocked) { + EditorCard( + icon = Icons.Default.Info, + iconTint = MaterialTheme.colorScheme.onSurfaceVariant, + iconAtTop = true, + ) { + Text( + text = stringResource(R.string.calendars_managed_delete_locked), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) { InlineTextField( value = name, @@ -694,6 +745,27 @@ private fun CalendarEditor( } } +/** + * The row's supporting line: the states that make this calendar behave unlike a + * plain writable one (#76), then its own description. Text rather than badges — + * a row can carry several of these at once next to a switch, which is exactly + * what M3 supporting text composes and a row of static chips doesn't. + */ +@Composable +private fun calendarRowSummary(calendar: CalendarSource): String? { + val states = calendar.stateLabels().map { label -> + stringResource( + when (label) { + CalendarStateLabel.MANAGED -> R.string.calendars_state_managed + CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only + CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced + }, + ) + } + val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() }) + return parts.joinToString(" · ").ifEmpty { null } +} + /** * The per-row on/off control, writing the system's `Calendars.VISIBLE`: checked * = the calendar is shown, unchecked = it drops out of every surface (events, 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 1635054..fb745ec 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 @@ -74,6 +74,33 @@ class CalendarsViewModel @Inject constructor( initialValue = AutoBackupUiState(), ) + /** + * Managed special-dates calendars whose deletion would not stick. While the + * feature is on, the sync owns every mirror: it recreates a missing one for + * an enabled type on the next pass and deletes the leftover of a disabled + * one (`SpecialDatesSyncEngine.reconcileCalendars`), so either way the + * delete would appear to work and then undo itself. Turning special dates + * off empties this set, and deleting a leftover mirror is a real delete from + * then on. + * + * Read off each calendar's own durable marker ([CalendarSource.isManaged], + * the `CAL_SYNC2` one the editor lock already trusts) rather than the stored + * ids, which are only rewritten on the next sync pass — a preferences loss + * would otherwise unlock a live mirror until then. + */ + val deleteLockedCalendarIds: StateFlow> = combine( + calendars, + settingsPrefs.specialDatesEnabled, + ) { sources, enabled -> + if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet() + } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = emptySet(), + ) + private val _error = MutableStateFlow(false) val error: StateFlow = _error.asStateFlow() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt index 8a25102..4d00227 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt @@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -42,12 +44,19 @@ import de.jeanlucmakiola.floret.components.SelectedCheck * account — with the calendars beneath it as a connected card, a colour chip on * each and a check on the selected one. Emits into the caller's [ColumnScope] * (a scrolling column), so the caller owns the surrounding chrome. + * + * The list holds event *targets* only, so a calendar that is switched off, + * read-only or managed is silently absent — which reads as a missing calendar + * rather than an excluded one (#76). [onManageCalendars], when given, adds the + * footer row that names the possible reasons and opens the calendar manager, + * where each row then says which one applies. */ @Composable fun ColumnScope.CalendarPickerGroups( calendars: List, selectedId: Long?, onSelect: (Long) -> Unit, + onManageCalendars: (() -> Unit)? = null, ) { val local = remember(calendars) { calendars.filter { it.isLocal } } val syncedGroups = remember(calendars) { @@ -75,6 +84,23 @@ fun ColumnScope.CalendarPickerGroups( onSelect = onSelect, ) } + if (onManageCalendars != null) { + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.calendar_picker_missing_title), + summary = stringResource(R.string.calendar_picker_missing_summary), + position = Position.Alone, + leading = { LeadingAvatar(Icons.Default.VisibilityOff) }, + trailing = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = onManageCalendars, + ) + } } /** One account's category header (avatar + name) atop its selectable calendars. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index b064a35..f3c36bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -89,6 +89,8 @@ import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -193,6 +195,7 @@ fun EventEditScreen( initialStartMinutes: Int? = null, initialForm: EventForm? = null, initialFormSource: ImportSource = ImportSource.File, + onManageCalendars: (() -> Unit)? = null, viewModel: EventEditViewModel = hiltViewModel(), ) { LaunchedEffect(initialDateIso, editKey, initialForm) { @@ -309,6 +312,7 @@ fun EventEditScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding), + onManageCalendars = onManageCalendars, ) } } @@ -500,6 +504,7 @@ private fun EventEditContent( state: EventEditUiState, viewModel: EventEditViewModel, modifier: Modifier = Modifier, + onManageCalendars: (() -> Unit)? = null, ) { val form = state.form val locale = currentLocale() @@ -509,6 +514,10 @@ private fun EventEditContent( // they're locked here; everything else (reminders, location, notes) is the // user's to edit. val locked = state.isManaged + // Read in the form's own window, not the picker's: the field holding focus + // lives here, so this is the controller that can put its keyboard away. + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current var picker by remember { mutableStateOf(null) } var showCalendarPicker by rememberSaveable { mutableStateOf(false) } var showReminderPicker by rememberSaveable { mutableStateOf(false) } @@ -1114,6 +1123,17 @@ private fun EventEditContent( null -> Unit } + // A full-screen picker over the form is a change of place, so the form's + // keyboard has no business following it there — least of all onto the + // calendar manager, which the picker can hand off to. The form's own field + // keeps its text; only focus and the IME go. + LaunchedEffect(showCalendarPicker) { + if (showCalendarPicker) { + focusManager.clearFocus(force = true) + keyboardController?.hide() + } + } + if (showCalendarPicker) { CalendarPicker( calendars = state.calendars, @@ -1122,6 +1142,17 @@ private fun EventEditContent( viewModel.setCalendar(it) showCalendarPicker = false }, + // Close the picker on the way out. It is a Compose Dialog — its own + // window, always above the activity's content — so the manager would + // otherwise open behind it and the tap would look dead. The form + // stays standing underneath, its calendar row one tap from a picker + // that re-queries on open. + onManageCalendars = onManageCalendars?.let { openManager -> + { + showCalendarPicker = false + openManager() + } + }, onDismiss = { showCalendarPicker = false }, ) } @@ -2277,6 +2308,7 @@ private fun CalendarPicker( selectedId: Long?, onSelect: (Long) -> Unit, onDismiss: () -> Unit, + onManageCalendars: (() -> Unit)? = null, ) { FullScreenPicker( title = stringResource(R.string.event_detail_calendar), @@ -2286,6 +2318,7 @@ private fun CalendarPicker( calendars = calendars, selectedId = selectedId, onSelect = onSelect, + onManageCalendars = onManageCalendars, ) } } 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 42cee09..d5284ec 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 @@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.domain.isEventTarget import de.jeanlucmakiola.calendula.domain.populatedFields import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.toEditSnapshot @@ -177,19 +178,16 @@ class EventEditViewModel @Inject constructor( repository.calendars().catch { emit(emptyList()) } /** - * Writable calendars — the only valid event targets. Calendars switched off - * in Settings → Calendars are excluded, so you can't create into one you've - * turned off; a last-used preselect landing on a now-off calendar falls back - * to the first remaining writable one (handled by [resolvedCalendarId] and - * [state]). Managed special-dates calendars are excluded too: their events - * are owned by the contact sync, which would delete any user event created - * there. + * The calendars a new event can be saved to ([isEventTarget]): writable, + * switched on, not a contact-filled mirror, not a non-syncing subscription. + * A last-used preselect landing on an excluded calendar falls back to the + * first remaining one (handled by [resolvedCalendarId] and [state]). * * This is the list of *targets*. An event already living in an excluded * calendar keeps it — [state] adds it back to the picker. */ private val writableCalendars: Flow> = allCalendars.map { calendars -> - calendars.filter { it.canModifyContents && it.isVisibleInSystem && !it.isManaged } + calendars.filter { it.isEventTarget } } /** The target calendar id, resolved exactly as the form shows it. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index c54b45f..253b02e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -75,6 +75,7 @@ fun ImportScreen( onClose: () -> Unit, onOpenSingle: (EventForm) -> Unit, forceMany: Boolean = false, + onManageCalendars: (() -> Unit)? = null, // Key the VM by the file uri. This screen has no nav backstack, so an // unkeyed hiltViewModel() resolves to the Activity's store and is retained // across imports — its one-shot `load` guard would then show the *previous* @@ -155,7 +156,12 @@ fun ImportScreen( ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose) ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose) - is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it }) + is ImportUiState.Many -> ManyContent( + state = s, + selected = selected, + onSelect = { selected = it }, + onManageCalendars = onManageCalendars, + ) is ImportUiState.Done -> DoneContent(s, onClose) } } @@ -163,10 +169,24 @@ fun ImportScreen( } @Composable -private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) { - // No writable calendar to import into — tell the user honestly. +private fun ManyContent( + state: ImportUiState.Many, + selected: Long?, + onSelect: (Long) -> Unit, + onManageCalendars: (() -> Unit)? = null, +) { + // No calendar to import into — tell the user honestly, and carry the same + // way out the picker's footer offers below. This is the state that footer + // exists for: every writable calendar being switched off, read-only or + // contact-filled is exactly what empties this list (#76). if (state.calendars.isEmpty()) { - CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null) + CenteredMessage( + message = stringResource(R.string.import_no_calendar), + onClose = null, + actionLabel = stringResource(R.string.settings_manage_calendars) + .takeIf { onManageCalendars != null }, + onAction = onManageCalendars, + ) return } @@ -178,6 +198,7 @@ private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (L calendars = state.calendars, selectedId = selected, onSelect = onSelect, + onManageCalendars = onManageCalendars, ) if (state.warnings.isNotEmpty()) { Column( @@ -333,7 +354,12 @@ private fun WarningText(warning: IcsParseWarning) { } @Composable -private fun CenteredMessage(message: String, onClose: (() -> Unit)?) { +private fun CenteredMessage( + message: String, + onClose: (() -> Unit)?, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, +) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( Modifier.padding(24.dp), @@ -344,6 +370,9 @@ private fun CenteredMessage(message: String, onClose: (() -> Unit)?) { if (onClose != null) { Button(onClick = onClose) { Text(stringResource(R.string.import_close)) } } + if (actionLabel != null && onAction != null) { + Button(onClick = onAction) { Text(actionLabel) } + } } } } 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 d7c536a..2e8e066 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 @@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning import de.jeanlucmakiola.calendula.domain.ics.IcsParser import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.toEventForm +import de.jeanlucmakiola.calendula.domain.isEventTarget import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -85,18 +86,14 @@ class ImportViewModel @Inject constructor( warnings = parsed.warnings, ) else -> { - // A calendar switched off in Settings → Calendars is off - // everywhere, so it can't be an import target — exclude it - // alongside the read-only ones. Managed special-dates - // calendars are contact-derived and editor-locked, so - // they're not a valid destination either. + // The same targets the event form offers ([isEventTarget]): + // an import is a bulk create, so a calendar that can't hold + // one event can't hold thirty. ImportUiState.Many( events = parsed.events, warnings = parsed.warnings, calendars = repository.calendars().first() - .filter { - it.canModifyContents && !it.isManaged && it.isVisibleInSystem - }, + .filter { it.isEventTarget }, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a382653..3e24629 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -477,6 +477,13 @@ Show \"%1$s\" Some calendars are switched off Calendula now shows the calendars that are switched on for this device, so what you see and what reminds you can no longer disagree. Some of yours are currently off — they were switched off here or in another calendar app. Turn any of them back on in Settings → Calendars. + + Missing a calendar? + It may be switched off, read-only, or filled from your contacts — manage your calendars here. + Read-only + Not synced to this device + Filled from your contacts + This calendar is filled from your contacts, so Calendula would create it again on the next sync. Turn special dates off under Settings → Special dates to delete it. Synced calendars These come from accounts on your device. Create and edit them in their own app. Manage in app diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt new file mode 100644 index 0000000..376bcd0 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt @@ -0,0 +1,110 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class CalendarRowStateTest { + + private fun cal( + id: Long = 1L, + name: String = "Cal $id", + writable: Boolean = true, + syncsEvents: Boolean = true, + local: Boolean = false, + managed: Boolean = false, + ) = CalendarSource( + id = id, + displayName = name, + accountName = "account", + accountType = if (local) "LOCAL" else "com.google", + color = 0, + isVisibleInSystem = true, + canModifyContents = writable, + isLocal = local, + syncsEvents = syncsEvents, + isManaged = managed, + ) + + @Test + fun `a plain writable calendar carries no state labels`() { + assertThat(cal().stateLabels()).isEmpty() + assertThat(cal().hasVisibilitySwitch).isTrue() + } + + @Test + fun `a read-only calendar is labelled`() { + assertThat(cal(writable = false).stateLabels()) + .containsExactly(CalendarStateLabel.READ_ONLY) + } + + @Test + fun `a non-syncing account calendar is labelled and loses its switch`() { + val calendar = cal(syncsEvents = false) + assertThat(calendar.stateLabels()).containsExactly(CalendarStateLabel.NOT_SYNCED) + assertThat(calendar.hasVisibilitySwitch).isFalse() + } + + @Test + fun `both states can hold at once, read-only first`() { + assertThat(cal(writable = false, syncsEvents = false).stateLabels()) + .containsExactly(CalendarStateLabel.READ_ONLY, CalendarStateLabel.NOT_SYNCED) + .inOrder() + } + + @Test + fun `a managed special-dates mirror is labelled although it is writable`() { + // Writable, visible, syncing — nothing else on the row would hint at why + // it can't be picked as an event target. + val calendar = cal(local = true, managed = true) + assertThat(calendar.stateLabels()).containsExactly(CalendarStateLabel.MANAGED) + assertThat(calendar.hasVisibilitySwitch).isTrue() + } + + @Test + fun `a local calendar is never called not-synced`() { + // Nothing syncs a device-local calendar, so sync_events says nothing + // about it — and another app's local calendar can hold real events at 0. + val calendar = cal(syncsEvents = false, local = true) + assertThat(calendar.isNotSynced).isFalse() + assertThat(calendar.stateLabels()).isEmpty() + assertThat(calendar.hasVisibilitySwitch).isTrue() + } + + @Test + fun `every named state keeps a calendar out of the pickers`() { + // The labels and the picker exclusion are the same set, stated twice — + // a labelled row the pickers still offered would make the footer's + // "manage your calendars to see why" a lie (#76). + assertThat(cal().isEventTarget).isTrue() + listOf( + cal(writable = false), + cal(syncsEvents = false), + cal(local = true, managed = true), + ).forEach { calendar -> + assertThat(calendar.stateLabels()).isNotEmpty() + assertThat(calendar.isEventTarget).isFalse() + } + } + + @Test + fun `a switched-off calendar is no target although it carries no label`() { + // The switch is right there on the row, so the state speaks for itself. + val calendar = cal().copy(isVisibleInSystem = false) + assertThat(calendar.stateLabels()).isEmpty() + assertThat(calendar.isEventTarget).isFalse() + } + + @Test + fun `manager order puts non-syncing calendars last and is otherwise stable`() { + val ordered = listOf( + cal(id = 1L, name = "Anna", syncsEvents = false), + cal(id = 2L, name = "Bert"), + cal(id = 3L, name = "Cleo", syncsEvents = false), + cal(id = 4L, name = "Dana"), + ).orderedForManager() + + assertThat(ordered.map { it.displayName }) + .containsExactly("Bert", "Dana", "Anna", "Cleo") + .inOrder() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt index 184be1b..e087fbe 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt @@ -45,9 +45,14 @@ class EventEditViewModelTest { private val beginMillis = 1_781_164_800_000L private val endMillis = beginMillis + 3_600_000L - private fun cal(id: Long, visible: Boolean = true): CalendarSource = CalendarSource( + private fun cal( + id: Long, + visible: Boolean = true, + syncsEvents: Boolean = true, + ): CalendarSource = CalendarSource( id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL", color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true, + syncsEvents = syncsEvents, ) private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail( @@ -108,6 +113,27 @@ class EventEditViewModelTest { job.cancel() } + @Test + fun `a calendar whose account is not synced to this device is not a target`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + // Writable and switched on, but the account keeps its events off the + // device: nothing saved here ever reaches it, and the provider drops the + // rows when the subscription comes back (#76). + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf(cal(1L), cal(2L, syncsEvents = false)) + eventDetailResult = { detail(calendarId = 1L) } + } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + + vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis) + advanceUntilIdle() + + assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L) + job.cancel() + } + @Test fun `editing an event in a switched-off calendar keeps it in the picker`( @TempDir tempDir: Path, From c70412c782d502a84147249f50a13c90778d460e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 28 Jul 2026 17:10:52 +0000 Subject: [PATCH 5/6] Link the privacy policy from the about card (!103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth link to the about card in settings, next to source and licence. Play requires the privacy policy to be reachable from inside the app, not just from the store listing, for anything that touches calendar or contact data. Points at jeanlucmakiola.de/calendula/privacy. Only the base string is added — Weblate owns the translations. Reviewed-on: https://gitea.jeanlucmakiola.de/makiolaj/calendula/pulls/103 --- .../calendula/ui/settings/SettingsScreen.kt | 10 +++++++++- app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) 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 c490375..c1efe32 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 @@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Keyboard 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.PrivacyTip import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.UploadFile @@ -408,7 +409,9 @@ private fun LanguageRow(position: Position) { @Composable private fun AboutCard() { // The card layout lives in floret-kit (components.AboutCard); Calendula - // supplies its own logo, author and the source / licence / support links. + // supplies its own logo, author and the source / licence / privacy / support + // links. The privacy policy has to be reachable from inside the app, not just + // from the store listing, because Calendula touches calendar and contact data. AboutCard( logo = { AppLogo() }, appName = stringResource(R.string.app_name), @@ -424,6 +427,11 @@ private fun AboutCard() { label = stringResource(R.string.settings_license), url = stringResource(R.string.about_license_url), ), + AboutLink( + icon = Icons.Default.PrivacyTip, + label = stringResource(R.string.settings_about_privacy), + url = stringResource(R.string.about_privacy_url), + ), ), highlightLink = AboutLink( icon = Icons.Default.Favorite, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef9bfce..99407b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -462,6 +462,7 @@ MIT by Jean-Luc Makiola Source + Privacy policy Support development Version %1$s Calendula app icon @@ -572,6 +573,7 @@ https://gitea.jeanlucmakiola.de/makiolaj/calendula https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE + https://jeanlucmakiola.de/calendula/privacy https://ko-fi.com/jeanlucmakiola https://weblate.dev.jeanlucmakiola.de/engage/calendula/ From db7094c54e328bb03d549c92c4f44eb4374e4155 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 29 Jul 2026 20:33:56 +0200 Subject: [PATCH 6/6] Move the canonical forge to Codeberg (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline. **This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home. ### 1 · floret-kit moved Mirrored to `jlmakiola/floret-kit` (6 branches, 3 tags, every SHA verified identical) and `.gitmodules` repointed. This is what actually unblocks contributors — a clone previously could not resolve its submodule without reaching the personal Gitea instance. The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases (including F-Droid reproducible rebuilds) still resolve. ### 2 · Workflows split by directory Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync: | Directory | Runs on | Contains | Secrets | | --- | --- | --- | --- | | `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** | | `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them | The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API. ### 3 · Two release-pipeline safety changes - `release.yaml`'s `detect` gets an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running the release pipeline on the contributor-facing runner, without secrets. - `detect` now reads tags from **Codeberg**, not from the Gitea instance it runs on. Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by the next sync until the Codeberg tag push propagates back — asking Gitea inside that window reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not. ### 4 · Links repointed In-app Source/License links, README badge, both F-Droid metadata files. **`Repo:` in `docs/fdroid-official/` deliberately stays on Gitea** — it keeps receiving `main` and every tag, so it remains a complete build source, and leaving it alone means no fdroiddata MR and no reproducible-build risk. ### Not in this PR Renovate + Weblate repointing, and the Codeberg → Gitea push mirror (browser-side). Supersedes Gitea PR #104. Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/86 --- {.gitea => .forgejo}/workflows/ci.yaml | 7 ++- .../workflows/translations.yaml | 0 .gitea/workflows/release.yaml | 51 +++++++++++++++---- .gitea/workflows/renovate.yml | 27 +++++++--- .gitmodules | 2 +- CHANGELOG.md | 7 +++ README.md | 2 +- app/src/main/res/values/strings.xml | 4 +- docs/RELEASING.md | 42 +++++++++++++-- .../de.jeanlucmakiola.calendula.yml | 6 +-- .../de.jeanlucmakiola.calendula.yml | 4 +- 11 files changed, 119 insertions(+), 33 deletions(-) rename {.gitea => .forgejo}/workflows/ci.yaml (92%) rename {.gitea => .forgejo}/workflows/translations.yaml (100%) diff --git a/.gitea/workflows/ci.yaml b/.forgejo/workflows/ci.yaml similarity index 92% rename from .gitea/workflows/ci.yaml rename to .forgejo/workflows/ci.yaml index 9f15571..65831ab 100644 --- a/.gitea/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -72,9 +72,14 @@ jobs: distribution: 'zulu' java-version: '17' + # Fully qualified on purpose. Codeberg resolves bare `uses:` refs against + # data.forgejo.org, Forgejo's own action mirror — actions/checkout, + # setup-java and cache all exist there, but android-actions/setup-android + # does not, and the job dies with "repository not found". Gitea's instance + # defaults to GitHub, which is why this never surfaced before the split. - name: Setup Android SDK if: steps.scope.outputs.code == 'true' - uses: android-actions/setup-android@v3 + uses: https://github.com/android-actions/setup-android@v3 with: # Default ("tools platform-tools") drags in the Android Emulator # (~300 MB) which the build never uses. diff --git a/.gitea/workflows/translations.yaml b/.forgejo/workflows/translations.yaml similarity index 100% rename from .gitea/workflows/translations.yaml rename to .forgejo/workflows/translations.yaml diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index bf0f83e..17a4efd 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -27,6 +27,14 @@ jobs: # whether this push actually cuts a new release (no tag for it yet). Keeps the # heavy job from running on every merge to main. detect: + # Gitea only. The workflow directory split already keeps this file invisible + # to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows + # exists — but that only holds while .forgejo/ is non-empty. Move the last + # file out of it and Codeberg would fall back to .gitea/workflows and start + # running the release pipeline on the contributor-facing runner, with no + # secrets. repository_owner differs between the two forges regardless of + # URL, proxy or instance rename, so this closes it permanently. + if: github.repository_owner == 'makiolaj' runs-on: docker outputs: is_release: ${{ steps.v.outputs.is_release }} @@ -41,8 +49,16 @@ jobs: - name: Resolve version and whether it is a new release id: v env: - TOKEN: ${{ secrets.GITHUB_TOKEN }} - API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + # Tags are read from Codeberg, which is canonical — deliberately NOT + # from the Gitea API this workflow runs on. The Codeberg -> Gitea sync + # is a push mirror, i.e. `git push --mirror`, which deletes refs the + # source does not have. A tag minted here on Gitea is therefore wiped + # by the next sync (Codeberg does not have it yet) and only reappears + # once the tag push at the end of this workflow propagates back. + # Asking Gitea inside that window would report "no tag" for a release + # that already shipped, and cut it a second time. + # Public repo, so this read needs no token. + TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula run: | set -e VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) @@ -60,15 +76,28 @@ jobs: fi # A tag for this version already existing means the release shipped on # an earlier push; do nothing. Absent => this merge cuts the release. - STATUS=$(curl -s -o /dev/null -w '%{http_code}' \ - -H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION") - if [ "$STATUS" = "200" ]; then - echo "Tag v$VERSION already exists — nothing to release." - echo "is_release=false" >> "$GITHUB_OUTPUT" - else - echo "No tag for v$VERSION yet — cutting the release." - echo "is_release=true" >> "$GITHUB_OUTPUT" - fi + # + # Anything other than a clean 200/404 is treated as fatal rather than + # as "no tag". A Codeberg outage or a network blip would otherwise + # read as absent and re-cut a release that has already shipped — + # republishing to F-Droid and Play. Failing here is recoverable; a + # duplicate release is not. + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000) + case "$STATUS" in + 200) + echo "Tag v$VERSION already exists on Codeberg — nothing to release." + echo "is_release=false" >> "$GITHUB_OUTPUT" + ;; + 404) + echo "No tag for v$VERSION on Codeberg yet — cutting the release." + echo "is_release=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2 + echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2 + exit 1 + ;; + esac # Releases: build + sign + publish, then mint the tag and Gitea release. # Also runs on manual dispatch, where it skips the build and just re-signs and diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml index 4a39197..53f7a53 100644 --- a/.gitea/workflows/renovate.yml +++ b/.gitea/workflows/renovate.yml @@ -29,18 +29,29 @@ jobs: - name: Run Renovate run: renovate env: - # Self-hosted Gitea, not github.com. - RENOVATE_PLATFORM: gitea - RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1 - # Bot-account token (Gitea secret). Needs repo read/write + PR scope. + # Renovate targets Codeberg (canonical) while still RUNNING on the + # Gitea runner. Moving the job to Codeberg would put a repo-write + # token on the contributor-facing runner, which is exactly what the + # .forgejo/ vs .gitea/ split exists to prevent — so the token stays + # where the other secrets live and only the API calls cross over. + # + # Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the + # pinned image ships a distinct forgejo platform module. + RENOVATE_PLATFORM: forgejo + RENOVATE_ENDPOINT: https://codeberg.org/api/v1 + # Codeberg bot-account token (Gitea secret). Needs repo read/write + + # PR scope on jlmakiola/calendula. RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} # Scope to this repo only — no org-wide autodiscovery. RENOVATE_AUTODISCOVER: 'false' - RENOVATE_REPOSITORIES: '["makiolaj/calendula"]' - # Commits/PRs authored as the bot, not a real maintainer. + RENOVATE_REPOSITORIES: '["jlmakiola/calendula"]' + # Commits/PRs authored as the bot, not a real maintainer. This address + # must be a verified email on the Codeberg bot account, otherwise the + # commits show up unattributed there. RENOVATE_GIT_AUTHOR: 'Renovate Bot ' - # Read-only github.com PAT (no scopes needed). We run on Gitea, but - # nearly every dependency is *released* on GitHub — without this, + # Read-only github.com PAT (no scopes needed). Unaffected by the forge + # move — nearly every dependency is *released* on GitHub, and without + # this, # changelog/release-note lookups hit the 60/h anonymous rate limit # and PRs arrive with an empty "Release Notes" section. RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }} diff --git a/.gitmodules b/.gitmodules index 103c066..b908ca7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "floret-kit"] path = floret-kit - url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git + url = https://codeberg.org/jlmakiola/floret-kit.git diff --git a/CHANGELOG.md b/CHANGELOG.md index c16f6ae..8fa6eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Calendula's source code now lives on **Codeberg**, where its issues already + were. The **Source code** and **License** links in Settings → About point + there, so reporting a bug and reading the code no longer land on two different + sites. Nothing about the app itself changes, and the F-Droid repository is + unaffected. + ## [2.16.0] — 2026-07-24 ### Added diff --git a/README.md b/README.md index f909377..f2e3b31 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Reads, writes, and reminds — on top of the system calendar, with zero network access.

-CI +CI Android 10+ Kotlin + Compose Material 3 Expressive diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99407b9..79fdb43 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -571,8 +571,8 @@ Add Quick Settings tile Add a “New event” tile to the Quick Settings panel. - https://gitea.jeanlucmakiola.de/makiolaj/calendula - https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE + https://codeberg.org/jlmakiola/calendula + https://codeberg.org/jlmakiola/calendula/src/branch/main/LICENSE https://jeanlucmakiola.de/calendula/privacy https://ko-fi.com/jeanlucmakiola https://weblate.dev.jeanlucmakiola.de/engage/calendula/ diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4bd4f03..4426944 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -73,7 +73,8 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0` CI and release are split so a change is built once on its PR and only does release work when a merge actually cuts a release: -- **`ci.yaml`** (on `pull_request`) — lint + unit tests + a debug assemble (and +- **`ci.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**) — lint + + unit tests + a debug assemble (and a Trivy scan), once per PR. Docs/metadata-only PRs skip the Android build but still report a green `CI` check. - **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap @@ -91,9 +92,9 @@ release work when a merge actually cuts a release: Alongside F-Droid, each release is mirrored to the Codeberg repo (`jlmakiola/calendula`) as a plain download for users who don't want F-Droid. -Gitea already **push-mirrors** branches and tags to Codeberg, but releases -aren't git objects and don't sync, so the pipeline creates the release over the -Codeberg API and attaches `calendula_v.apk` + its `.sha256`. It's the +Codeberg **push-mirrors** branches and tags to Gitea, but releases aren't git +objects and don't sync in either direction, so the pipeline creates the release +over the Codeberg API and attaches `calendula_v.apk` + its `.sha256`. It's the same APK the F-Droid repo serves (same **app key**), so it adds no trust surface. The step is best-effort: a Codeberg outage never fails an already-published F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is @@ -109,6 +110,39 @@ build, the version bump, and tag/release creation, and just re-signs the existing F-Droid index with the configured repo key and re-uploads. Use this for key rotation or repo recovery without publishing a new app version. +## Two forges, one repo + +**Codeberg (`jlmakiola/calendula`) is canonical** — git, issues, PRs, tags and +releases. The self-hosted Gitea instance is build infrastructure: it holds the +signing key, publishes the F-Droid repo, and runs the release pipeline. Codeberg +push-mirrors `main` and tags to Gitea, and a bumped `versionName` arriving there +triggers `release.yaml` exactly as before. + +Workflows are separated by **directory**, not by conditionals. Forgejo looks in +`.forgejo/workflows` → `.gitea/workflows` → `.github/workflows` and stops at the +first that exists; Gitea doesn't know `.forgejo/` at all: + +| Directory | Runs on | Contains | Secrets | +| --- | --- | --- | --- | +| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** | +| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | signing key, F-Droid, Play, bot tokens | + +The line is drawn at **secrets, not at CI-vs-release**. That's what makes fork +PRs safe: everything a contributor can trigger lives in `.forgejo/` and can +reference no secret. Renovate stays on the Gitea runner *even though it opens +PRs on Codeberg* — it talks to Codeberg's API rather than moving its token onto +the contributor-facing runner. + +Two consequences worth remembering: + +- **`detect` reads tags from Codeberg**, not from the Gitea instance it runs on. + Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by + the next sync until the Codeberg tag push propagates back. Asking Gitea inside + that window would re-cut a shipped release. +- **Any ref that exists only on Gitea gets deleted** by the mirror. That's + correct under Codeberg-canonical, but don't debug a "vanished" branch without + remembering it. + ## Secrets (Gitea → repo Settings → Actions → Secrets) | Secret | Purpose | diff --git a/docs/fdroid-official/de.jeanlucmakiola.calendula.yml b/docs/fdroid-official/de.jeanlucmakiola.calendula.yml index 86b95a7..aa9c737 100644 --- a/docs/fdroid-official/de.jeanlucmakiola.calendula.yml +++ b/docs/fdroid-official/de.jeanlucmakiola.calendula.yml @@ -18,9 +18,9 @@ Categories: - Calendar & Agenda License: MIT 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 +SourceCode: https://codeberg.org/jlmakiola/calendula +IssueTracker: https://codeberg.org/jlmakiola/calendula/issues +Changelog: https://codeberg.org/jlmakiola/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 2f6581e..61c667b 100644 --- a/fdroid-metadata/de.jeanlucmakiola.calendula.yml +++ b/fdroid-metadata/de.jeanlucmakiola.calendula.yml @@ -6,6 +6,6 @@ Summary: A modern Material 3 Expressive calendar for Android. Categories: - Time -SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula -IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues +SourceCode: https://codeberg.org/jlmakiola/calendula +IssueTracker: https://codeberg.org/jlmakiola/calendula/issues Donate: https://ko-fi.com/jeanlucmakiola