From c04b2ecee2638000cf6e529ce16ca19d354d1896 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 7 Sep 2026 22:29:03 +0200 Subject: [PATCH] Release v2.20.1 (#288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release 2.20.1. **Fixed** - "No calendars configured" offered an "Open system calendar settings" button that opened nothing — every screen wired it to the same reload that had just produced the empty state. It now opens the system's account chooser, filtered to the providers that sync calendars ([#239]). - A device whose calendars are all switched off no longer looks like an empty month. The app only ever checked whether the device had *no* calendars, so calendars hidden at system level fell through to a blank grid with no hint. That case now says so and points at Settings → Calendars. The navigation drawer's calendar list drew the same conflation and is fixed with it ([#239]). **Beyond the issue** Two things in here aren't part of #239: - `fastlane/metadata/android/en-US/changelogs/22001.txt` carries the 2.20.0 notes as well as 2.20.1's. The 2.20.0 Play upload failed on a service-account permission (it lacked "Release to production"; 2.19.4 failed the same way), so Play is still on 2.19.3 and users jump straight to 2.20.1. Left alone they would have seen only the 2.20.1 bugfixes and never heard about the hour grid, copyable event fields or invitation outlines. 402 characters, inside Play's 500 limit. The permission has since been granted, so this release should be the one that lands. - A merge of `main` into the branch. `main` took 2.20.0 as a squash, so the two had no common ancestor past 2.19.4 and this PR would otherwise have replayed all 74 files of 2.20.0. Resolved to the branch throughout; the tree is byte-for-byte unchanged by it. Closes #239. Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/288 --- CHANGELOG.md | 18 ++++++++ app/build.gradle.kts | 4 +- .../domain/CalendarVisibilityPlan.kt | 16 ++++++++ .../jeanlucmakiola/calendula/domain/Models.kt | 1 + .../calendula/ui/CalendarHost.kt | 8 +++- .../calendula/ui/agenda/AgendaViewModel.kt | 5 +-- .../calendula/ui/common/CalendarFailure.kt | 41 ++++++++++++++++++- .../calendula/ui/day/DayViewModel.kt | 5 +-- .../calendula/ui/filter/CalendarFilterList.kt | 1 + .../calendula/ui/filter/FilterViewModel.kt | 6 ++- .../calendula/ui/month/MonthViewModel.kt | 9 ++-- .../calendula/ui/week/WeekViewModel.kt | 5 +-- app/src/main/res/values/strings.xml | 2 + .../domain/CalendarVisibilityPlanTest.kt | 20 +++++++++ .../calendula/domain/ModelsTest.kt | 3 +- .../android/en-US/changelogs/22001.txt | 11 +++++ 16 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/22001.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 6408bef..dc9ea17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.20.1] — 2026-09-07 + +### Fixed +- **An empty calendar now explains itself.** "No calendars configured" offered + an "Open system calendar settings" button that opened nothing — every screen + wired it to the same reload that had just produced the empty state. It now + opens the system's account chooser, filtered to the providers that sync + calendars ([#239]). +- **Calendars that are all switched off no longer look like an empty month.** + When every calendar on the device is hidden at system level there is nothing + to draw, but the app said nothing at all: it only ever checked whether the + device had *no* calendars, so a device that had them and hid them fell + through to a blank grid with no hint. That case now says so, and points at + Settings → Calendars — the one screen that can switch a calendar back on. The + navigation drawer's calendar list drew the same conflation and is fixed with + it ([#239]). + ## [2.20.0] — 2026-09-07 ### Added @@ -1654,3 +1671,4 @@ automatically, with zero telemetry and no internet permission. [#150]: https://codeberg.org/jlmakiola/calendula/issues/150 [#146]: https://codeberg.org/jlmakiola/calendula/issues/146 [#267]: https://codeberg.org/jlmakiola/calendula/issues/267 +[#239]: https://codeberg.org/jlmakiola/calendula/issues/239 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d0303be..80e35f6 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 = 22000 - versionName = "2.20.0" + versionCode = 22001 + versionName = "2.20.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarVisibilityPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarVisibilityPlan.kt index f9b84dc..6ccb621 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarVisibilityPlan.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarVisibilityPlan.kt @@ -46,3 +46,19 @@ fun hasSystemHiddenCalendars( calendars: List, pendingDisabledIds: Set, ): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds } + +/** + * The failure a calendar list implies, or null when there is something to show. + * Distinguishes "the device has no calendars" from "every one of them is + * switched off" (#239) — both leave the grid empty, but only the second is + * fixed from Settings → Calendars. + * + * The filter sheet's own hidden set is deliberately not folded in: emptying it + * is a deliberate act taken moments earlier, in a sheet that is still one tap + * away. + */ +fun calendarListFailure(calendars: List): FailureReason? = when { + calendars.isEmpty() -> FailureReason.NoCalendarsConfigured + calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden + else -> null +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index fa3fad0..7fba181 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -252,6 +252,7 @@ enum class RecurringWriteScope { enum class FailureReason { PermissionRevoked, NoCalendarsConfigured, + AllCalendarsHidden, ProviderUnavailable, EventNotFound, Unknown, 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 2ef93d5..0141d94 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -31,6 +31,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.EventMoveHost import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.LocalEventMove +import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel import de.jeanlucmakiola.calendula.ui.common.drillToDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec @@ -344,7 +345,12 @@ fun CalendarHost( // navigation, so it fades through rather than sliding — paging *within* a // view keeps the directional slide. AnimatedContent keyed on the view type. val viewSwitch = fadeThrough() - CompositionLocalProvider(LocalEventMove provides moveScope) { + CompositionLocalProvider( + LocalEventMove provides moveScope, + // The failure state's way out when every calendar is switched off + // (#239); nothing else in the calendar surfaces navigates here. + LocalManageCalendars provides { showCalendars = true }, + ) { AnimatedContent( targetState = view, transitionSpec = { viewSwitch }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index e288003..e894f40 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.domain.calendarListFailure import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -156,9 +157,7 @@ class AgendaViewModel @Inject constructor( calendars: List, instances: List, ): AgendaUiState { - if (calendars.isEmpty()) { - return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured) - } + calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) } val anchor = params.anchor val rangeEnd = anchor.plus( params.range.dayCount(anchor, params.weekStart) - 1, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt index 09a761e..8eee97a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt @@ -1,5 +1,9 @@ package de.jeanlucmakiola.calendula.ui.common +import android.content.Context +import android.content.Intent +import android.provider.CalendarContract +import android.provider.Settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -10,8 +14,10 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -24,18 +30,29 @@ import de.jeanlucmakiola.calendula.domain.FailureReason */ @Composable fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) { + val context = LocalContext.current + val manageCalendars = LocalManageCalendars.current val titleRes = when (reason) { FailureReason.PermissionRevoked -> R.string.state_failure_permission FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars + FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden FailureReason.ProviderUnavailable -> R.string.state_failure_provider FailureReason.Unknown, FailureReason.EventNotFound -> R.string.state_failure_unknown } val actionRes = when (reason) { FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action + FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden_action FailureReason.PermissionRevoked -> R.string.state_failure_permission_action else -> R.string.state_retry } + val onAction: () -> Unit = when (reason) { + FailureReason.NoCalendarsConfigured -> { + { context.startCalendarSetup() } + } + FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry + else -> onRetry + } Column( modifier = Modifier .fillMaxSize() @@ -49,8 +66,30 @@ fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) { textAlign = TextAlign.Center, ) Spacer(Modifier.height(24.dp)) - FilledTonalButton(onClick = onRetry) { + FilledTonalButton(onClick = onAction) { Text(stringResource(actionRes)) } } } + +/** + * Opens Settings → Calendars, the only screen that can switch a calendar back + * on. Null outside the calendar host — screens without it never raise the + * reason that needs it, and fall back to their own retry. + */ +val LocalManageCalendars = compositionLocalOf<(() -> Unit)?> { null } + +/** + * Open the system screen for putting a calendar on the device: the account + * chooser filtered to calendar providers, falling back to account settings + * where no chooser resolves. Both are system components, so nothing here needs + * a `` entry. + */ +private fun Context.startCalendarSetup() { + val intents = listOf( + Intent(Settings.ACTION_ADD_ACCOUNT) + .putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)), + Intent(Settings.ACTION_SYNC_SETTINGS), + ) + intents.firstOrNull { runCatching { startActivity(it) }.isSuccess } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt index 961d0c3..4146aee 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt @@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.layoutDay import kotlinx.coroutines.CoroutineDispatcher @@ -88,9 +89,7 @@ class DayViewModel @Inject constructor( calendars: List, instances: List, ): DayUiState { - if (calendars.isEmpty()) { - return DayUiState.Failure(FailureReason.NoCalendarsConfigured) - } + calendarListFailure(calendars)?.let { return DayUiState.Failure(it) } val days = listOf(day) val allDay = instances.filter { it.isAllDay } val timed = instances.filterNot { it.isAllDay } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt index a7699d7..7bf8480 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt @@ -125,6 +125,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) { private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) { val msg = when (reason) { FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars + FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden FailureReason.PermissionRevoked -> R.string.state_failure_permission else -> R.string.state_failure_provider } 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 86d7924..218c364 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 @@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.ui.common.groupByAccount import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.SharingStarted @@ -35,8 +36,9 @@ class FilterViewModel @Inject constructor( // Calendars switched off device-wide don't belong in the drawer's // hide/show list; they live only in Settings → Calendars. val enabled = calendars.filter { it.isVisibleInSystem } - if (enabled.isEmpty()) { - FilterUiState.Failure(FailureReason.NoCalendarsConfigured) + val failure = calendarListFailure(calendars) + if (failure != null) { + FilterUiState.Failure(failure) } else { FilterUiState.Success(groupCalendarsForFilter(enabled, hidden)) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index a7a70a9..9123823 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay @@ -208,9 +209,7 @@ class MonthViewModel @Inject constructor( calendars: List, instances: List, ): ContinuousMonthUiState { - if (calendars.isEmpty()) { - return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured) - } + calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) } // Bucketed once for the whole window: a row then takes the handful of // events on its seven days instead of re-scanning a year of them. val byDay = DayIndex(instances, zone) @@ -314,9 +313,7 @@ class MonthViewModel @Inject constructor( calendars: List, instances: List, ): MonthUiState { - if (calendars.isEmpty()) { - return MonthUiState.Failure(FailureReason.NoCalendarsConfigured) - } + calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) } val weeks = layoutMonthWeeks(ym, weekStart, instances, zone) return MonthUiState.Success( month = ym, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 26ca439..edaed06 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.domain.isDeclined import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -126,9 +127,7 @@ class WeekViewModel @Inject constructor( calendars: List, instances: List, ): WeekUiState { - if (calendars.isEmpty()) { - return WeekUiState.Failure(FailureReason.NoCalendarsConfigured) - } + calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) } val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) } val allDay = instances.filter { it.isAllDay } val timed = instances.filterNot { it.isAllDay } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 982ee2e..40e9971 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,8 @@ Grant access No calendars configured. Open system calendar settings + All your calendars are switched off. + Manage calendars Could not read the calendar. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarVisibilityPlanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarVisibilityPlanTest.kt index 8f01442..fa53d15 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarVisibilityPlanTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarVisibilityPlanTest.kt @@ -121,4 +121,24 @@ class CalendarVisibilityPlanTest { ), ).isTrue() } + + @Test + fun `no calendars on the device is its own failure`() { + assertThat(calendarListFailure(emptyList())) + .isEqualTo(FailureReason.NoCalendarsConfigured) + } + + @Test + fun `calendars that are all switched off read as hidden, not missing`() { + // The remedy differs: one needs a calendar made, the other needs one + // switched back on (#239). + assertThat(calendarListFailure(listOf(cal(1L, visible = false), cal(2L, visible = false)))) + .isEqualTo(FailureReason.AllCalendarsHidden) + } + + @Test + fun `one visible calendar is enough to show something`() { + assertThat(calendarListFailure(listOf(cal(1L, visible = false), cal(2L, visible = true)))) + .isNull() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt index 2209173..324acf6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ModelsTest.kt @@ -36,11 +36,12 @@ class ModelsTest { } @Test - fun `FailureReason enum has all five variants`() { + fun `FailureReason enum has all six variants`() { assertThat(FailureReason.values().toSet()).isEqualTo( setOf( FailureReason.PermissionRevoked, FailureReason.NoCalendarsConfigured, + FailureReason.AllCalendarsHidden, FailureReason.ProviderUnavailable, FailureReason.EventNotFound, FailureReason.Unknown, diff --git a/fastlane/metadata/android/en-US/changelogs/22001.txt b/fastlane/metadata/android/en-US/changelogs/22001.txt new file mode 100644 index 0000000..4e9195c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/22001.txt @@ -0,0 +1,11 @@ +New +• Long-press an event's title, location or description to copy. +• Settings → About lists the open-source projects we ship. + +Changed +• Week & day: each hour sits in its own cell, not a line. +• Unanswered invitations are drawn as outlines. + +Fixed +• "No calendars configured" now really opens the system calendar setup. +• When every calendar is switched off, the app says so instead of a blank month.