feat: spice up the import-complete screen

Replace the plain centered text list with an M3 Expressive success state:
a tonal check badge that springs in, the headline, and big-number tonal
stat tiles for added / duplicate-skipped counts, with a full-width Done
button. Stat tiles carry the full-sentence plurals as accessibility
labels so TalkBack still reads 'Imported N events'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 18:32:20 +02:00
parent 993d74502f
commit 8fb4767888
2 changed files with 120 additions and 19 deletions

View File

@@ -1,15 +1,27 @@
package de.jeanlucmakiola.calendula.ui.imports package de.jeanlucmakiola.calendula.ui.imports
import android.net.Uri import android.net.Uri
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -18,6 +30,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@@ -30,6 +43,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -139,41 +157,122 @@ private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
@Composable @Composable
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) { private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
// A little expressive pop on the success badge — springs in on first show.
val badgeScale = remember { Animatable(0.7f) }
LaunchedEffect(Unit) {
badgeScale.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
),
)
}
Column( Column(
Modifier.fillMaxSize().padding(24.dp), Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Spacer(Modifier.weight(1f))
Box(
Modifier
.size(112.dp)
.graphicsLayer {
scaleX = badgeScale.value
scaleY = badgeScale.value
}
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
Spacer(Modifier.height(24.dp))
Text( Text(
stringResource(R.string.import_done_title), stringResource(R.string.import_done_title),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(top = 24.dp), color = MaterialTheme.colorScheme.onSurface,
) )
Text( Spacer(Modifier.height(24.dp))
pluralStringResource( Row(
R.plurals.import_done_imported, Modifier.fillMaxWidth(),
state.summary.imported, horizontalArrangement = Arrangement.spacedBy(12.dp),
state.summary.imported, ) {
), ImportStatCard(
style = MaterialTheme.typography.bodyLarge, count = state.summary.imported,
) label = stringResource(R.string.import_done_added_label),
if (state.summary.skippedDuplicate > 0) { contentDescription = pluralStringResource(
Text( R.plurals.import_done_imported,
pluralStringResource( state.summary.imported,
R.plurals.import_done_skipped, state.summary.imported,
state.summary.skippedDuplicate,
state.summary.skippedDuplicate,
), ),
style = MaterialTheme.typography.bodyMedium, container = MaterialTheme.colorScheme.secondaryContainer,
color = MaterialTheme.colorScheme.onSurfaceVariant, onContainer = MaterialTheme.colorScheme.onSecondaryContainer,
) )
if (state.summary.skippedDuplicate > 0) {
ImportStatCard(
count = state.summary.skippedDuplicate,
label = stringResource(R.string.import_done_skipped_label),
contentDescription = pluralStringResource(
R.plurals.import_done_skipped,
state.summary.skippedDuplicate,
state.summary.skippedDuplicate,
),
container = MaterialTheme.colorScheme.surfaceContainerHighest,
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) { Spacer(Modifier.weight(1f))
Button(
onClick = onClose,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.import_close)) Text(stringResource(R.string.import_close))
} }
} }
} }
/** A big-number tonal tile summarising one import outcome (added / skipped). */
@Composable
private fun RowScope.ImportStatCard(
count: Int,
label: String,
contentDescription: String,
container: Color,
onContainer: Color,
) {
Surface(
modifier = Modifier
.weight(1f)
.clearAndSetSemantics { this.contentDescription = contentDescription },
shape = RoundedCornerShape(24.dp),
color = container,
) {
Column(
Modifier.padding(vertical = 20.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
count.toString(),
style = MaterialTheme.typography.displaySmall,
color = onContainer,
)
Text(
label,
style = MaterialTheme.typography.labelLarge,
color = onContainer.copy(alpha = 0.85f),
)
}
}
}
@Composable @Composable
private fun WarningText(warning: IcsParseWarning) { private fun WarningText(warning: IcsParseWarning) {
val text = when (warning) { val text = when (warning) {

View File

@@ -462,6 +462,8 @@
<string name="import_failed">Couldn\'t read this file.</string> <string name="import_failed">Couldn\'t read this file.</string>
<string name="import_no_calendar">No writable calendar to import into. Create a local calendar first.</string> <string name="import_no_calendar">No writable calendar to import into. Create a local calendar first.</string>
<string name="import_done_title">Import complete</string> <string name="import_done_title">Import complete</string>
<string name="import_done_added_label">Added</string>
<string name="import_done_skipped_label">Duplicates</string>
<string name="import_close">Close</string> <string name="import_close">Close</string>
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string> <string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
<string name="import_warning_no_start">An event without a start time was skipped.</string> <string name="import_warning_no_start">An event without a start time was skipped.</string>