From ad675dc22b0bd4d62c3d91ed57db3979601498c5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 22 Sep 2026 09:27:32 +0200 Subject: [PATCH] Fix the Manage calendars way out and reformat the failure state (#304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opened from Settings, the backup screen has no calendar manager underneath, so popping back landed on Settings. The action now opens the manager, which is still a plain pop back when the manager is what opened the screen. The failure state was one long string set as a headline. It now has a tonal icon, a short headline and a supporting line, capped at a readable measure — and the screen hands it an unscrolled column so it can actually centre. --- .../calendula/ui/CalendarHost.kt | 12 +- .../calendula/ui/calendars/BackupScreen.kt | 11 +- .../calendula/ui/common/CalendarFailure.kt | 157 +++++++++++++++--- .../calendula/ui/detail/EventDetailScreen.kt | 1 + app/src/main/res/values/strings.xml | 9 +- 5 files changed, 159 insertions(+), 31 deletions(-) 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 f34b83c..2011bfb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -550,10 +550,16 @@ fun CalendarHost( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), ) { - // The manager sits directly under this screen, so its failure states' - // "Manage calendars" way out is simply popping back to it (#304). + // Settings can open this screen without the manager underneath, so + // its failure states' "Manage calendars" way out has to open the + // manager rather than just pop — popping alone lands on Settings + // (#304). Coming from the manager the flag is already set, so this + // is the same pop-back there. CompositionLocalProvider( - LocalManageCalendars provides { showBackup = false }, + LocalManageCalendars provides { + showCalendars = true + showBackup = false + }, ) { BackupScreen( onBack = { showBackup = false }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt index 9a1fd48..6fc13fc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -130,6 +131,10 @@ fun BackupScreen( CollapsingScaffold( title = stringResource(R.string.settings_section_backup), onBack = onBack, + // Loading and failure fill the screen and centre themselves, which they + // can only do in an unscrolled column — the scrolling one measures them + // against an unbounded height and leaves them hanging under the header. + scrollable = state is BackupUiState.Ready, snackbarHost = { SnackbarHost(snackbarHostState) }, predictiveBack = true, ) { @@ -172,13 +177,11 @@ fun BackupScreen( } } -/** The screen's one-line loading state, in the scaffold's content column. */ +/** The screen's loading state, centred in the scaffold's content column. */ @Composable private fun BackupLoading() { Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 48.dp), + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { CircularProgressIndicator() 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 794e38f..3db5b96 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 @@ -4,19 +4,36 @@ import android.content.Context import android.content.Intent import android.provider.CalendarContract import android.provider.Settings +import androidx.annotation.StringRes +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.EditOff +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.EventBusy +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.SyncProblem +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon 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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -26,28 +43,18 @@ import de.jeanlucmakiola.calendula.domain.FailureReason /** * Full-screen failure state shared by every calendar screen (spec §7). - * One explanation line + one recovery action, never a toast. + * A tonal icon, one headline, one supporting line and one recovery action — + * never a toast. */ @Composable -fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) { +fun CalendarFailure( + reason: FailureReason, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { 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.NoImportTarget -> R.string.state_failure_no_import_target - 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.NoImportTarget -> R.string.state_failure_all_hidden_action - FailureReason.PermissionRevoked -> R.string.state_failure_permission_action - else -> R.string.state_retry - } + val copy = failureCopy(reason) val onAction: () -> Unit = when (reason) { FailureReason.NoCalendarsConfigured -> { { context.startCalendarSetup() } @@ -58,24 +65,128 @@ fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) { else -> onRetry } Column( - modifier = Modifier + modifier = modifier .fillMaxSize() - .padding(32.dp), + .padding(horizontal = 32.dp, vertical = 24.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { + FailureIcon(icon = copy.icon, isError = copy.isError) + Spacer(Modifier.height(24.dp)) Text( - text = stringResource(titleRes), + text = stringResource(copy.title), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH), ) - Spacer(Modifier.height(24.dp)) + copy.body?.let { body -> + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH), + ) + } + Spacer(Modifier.height(32.dp)) FilledTonalButton(onClick = onAction) { - Text(stringResource(actionRes)) + Text(stringResource(copy.action)) } } } +/** The state's mark: the reason's icon in a tonal circle above the headline. */ +@Composable +private fun FailureIcon(icon: ImageVector, isError: Boolean) { + val container: Color + val content: Color + if (isError) { + container = MaterialTheme.colorScheme.errorContainer + content = MaterialTheme.colorScheme.onErrorContainer + } else { + container = MaterialTheme.colorScheme.secondaryContainer + content = MaterialTheme.colorScheme.onSecondaryContainer + } + Box( + modifier = Modifier + .size(72.dp) + .background(container, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = content, + modifier = Modifier.size(36.dp), + ) + } +} + +/** + * What a reason says and offers. Headline and supporting line are separate so + * the explanation reads as body text instead of a headline that runs over three + * lines; [isError] tints the mark for the two reasons that are an actual fault + * rather than a calendar the user can switch back on. + */ +private data class FailureCopy( + val icon: ImageVector, + @StringRes val title: Int, + @StringRes val body: Int?, + @StringRes val action: Int, + val isError: Boolean = false, +) + +private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) { + FailureReason.PermissionRevoked -> FailureCopy( + icon = Icons.Outlined.Lock, + title = R.string.state_failure_permission, + body = R.string.state_failure_permission_body, + action = R.string.state_failure_permission_action, + ) + FailureReason.NoCalendarsConfigured -> FailureCopy( + icon = Icons.Outlined.CalendarMonth, + title = R.string.state_failure_no_calendars, + body = R.string.state_failure_no_calendars_body, + action = R.string.state_failure_no_calendars_action, + ) + FailureReason.AllCalendarsHidden -> FailureCopy( + icon = Icons.Outlined.VisibilityOff, + title = R.string.state_failure_all_hidden, + body = R.string.state_failure_all_hidden_body, + action = R.string.state_failure_all_hidden_action, + ) + FailureReason.NoImportTarget -> FailureCopy( + icon = Icons.Outlined.EditOff, + title = R.string.state_failure_no_import_target, + body = R.string.state_failure_no_import_target_body, + action = R.string.state_failure_all_hidden_action, + ) + FailureReason.EventNotFound -> FailureCopy( + icon = Icons.Outlined.EventBusy, + title = R.string.state_failure_event_not_found, + body = R.string.state_failure_event_not_found_body, + action = R.string.state_retry, + ) + FailureReason.ProviderUnavailable -> FailureCopy( + icon = Icons.Outlined.SyncProblem, + title = R.string.state_failure_provider, + body = R.string.state_failure_provider_body, + action = R.string.state_retry, + isError = true, + ) + FailureReason.Unknown -> FailureCopy( + icon = Icons.Outlined.ErrorOutline, + title = R.string.state_failure_unknown, + body = null, + action = R.string.state_retry, + isError = true, + ) +} + +/** Keeps the headline and its supporting line at a readable measure. */ +private val TEXT_MAX_WIDTH = 320.dp + /** * Opens Settings → Calendars, the only screen that can switch a calendar back * on. Null outside the calendar host — screens without it never raise the diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 12a32ee..c183e61 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -302,6 +302,7 @@ fun EventDetailScreen( is EventDetailUiState.Failure -> CalendarFailure( reason = s.reason, onRetry = viewModel::retry, + modifier = contentModifier, ) is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81224c2..4e18d0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,13 +11,20 @@ Retry Something went wrong. Calendar access is required. + Calendula reads your events straight from the system calendar, so it needs calendar access to show anything. Grant access No calendars configured. + Add a calendar account to this device and its calendars show up here. Open system calendar settings All your calendars are switched off. + Switch at least one calendar back on to see its events. Manage calendars - None of your calendars can take new events. They are read-only, managed by another app, or not synced to this device. + No calendar can take new events. + The calendars that are switched on are read-only, managed by another app, or not synced to this device. Switch on a calendar that can take events, or add a local one. + This event is no longer there. + It may have been deleted, or moved to a calendar that is switched off. Could not read the calendar. + The system calendar did not answer. Try again in a moment. Copy