diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt new file mode 100644 index 0000000..2979ee9 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt @@ -0,0 +1,162 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * A short procedure the user has to carry out somewhere else, as a connected + * list of numbered steps. + * + * A component rather than a paragraph, because the instructions that matter most + * in a sign-in flow are for a *different* app — go to this site, turn that + * setting on, bring the result back here — and four actions written as prose is + * the form in which people miss the third one. + * + * Built from [GroupedRow] rather than as a tinted card, so a procedure looks + * like every other run of rows in the family: one rounded container, full + * corners at the ends, small ones between. That buys two things a card did not. + * Each step is a row of its own, so a long one wraps inside its own container + * instead of pushing the rest of a paragraph around; and the numerals sit in the + * leading slot every other row puts an avatar or icon in, which is what makes + * the column of them scan as a sequence. + * + * [title] renders as the group's [GroupedSectionHeader] — omit it where the + * screen already carries the headline, or the same sentence is said twice. + * [footnote] is the caveat that is not a step: what this will cost, or what it + * will not do. + * + * [collapsible] folds the whole thing behind its [title] as a row that opens on + * a tap, closed to begin with. For a procedure the user has *already been shown* + * — carried along afterwards so it can be re-read rather than remembered — that + * is the difference between a reference and a wall repeating itself. It needs a + * [title], since collapsed there is nothing else to tap. + */ +@Composable +fun InstructionSteps( + steps: List, + modifier: Modifier = Modifier, + title: String? = null, + footnote: String? = null, + collapsible: Boolean = false, +) { + var open by rememberSaveable { mutableStateOf(false) } + // Collapsed only where there is a header to reopen from. + val folds = collapsible && title != null + Column(modifier) { + when { + folds -> { + GroupedRow( + title = title, + // Alone while shut, the head of the run once open — the + // corners are what say whether anything follows. + position = if (open) Position.Top else Position.Alone, + minHeight = 56.dp, + // No disc: the empty leading slot is what tells the header + // apart from the numbered rows it opens. + trailing = { Chevron(open) }, + onClick = { open = !open }, + ) + } + + title != null -> GroupedSectionHeader(title) + } + if (folds && !open) return@Column + steps.forEachIndexed { index, step -> + GroupedRow( + title = step, + // The header is the group's first row when there is one, so the + // steps run from the middle rather than starting the shape over. + position = if (folds) { + positionOf(index + 1, steps.size + 1) + } else { + positionOf(index, steps.size) + }, + // Roomy enough for one line, free to grow for three. A step that + // wraps is the normal case here, not the exception. + minHeight = 56.dp, + leading = { StepNumber(index + 1) }, + ) + } + footnote?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + // Outside the cards but belonging to them, so it starts on the + // same line they do. + modifier = Modifier.padding( + start = GroupedListInset, + end = GroupedListInset, + top = 8.dp, + ), + ) + } + } +} + +/** + * The step's number, in the leading slot. + * + * A filled `primary` disc rather than a bare numeral: on a neutral row the + * accent is what marks the column as a sequence rather than a list of unrelated + * settings, and it holds that reading at a glance without a rule down the side. + * + */ +@Composable +private fun StepNumber(number: Int) { + Box( + modifier = Modifier + .size(StepNumberSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + text = number.toString(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +/** + * The open/shut marker. + * + * Decorative: the row it sits on carries the name and the click, so a screen + * reader announcing a chevron as well would be describing the furniture. + */ +@Composable +private fun Chevron(open: Boolean) { + val turn by animateFloatAsState(if (open) 180f else 0f, label = "instruction-chevron") + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.rotate(turn), + ) +} + +/** The leading slot's own size, matched to the icons that usually fill it. */ +private val StepNumberSize = 28.dp