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