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.