components: numbered instruction steps

A connected list for a procedure the user has to carry out somewhere
else. Collapsible, so it can be shown again as a reference once it has
already had a screen of its own.
This commit is contained in:
2026-09-09 20:57:53 +02:00
parent 97b4d24724
commit 103a37cb28
@@ -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<String>,
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