feat(components): checked rows, switch rows, custom-expand row and option rows

GroupedRow gains checked, OptionPicker gains dismissOnSelect and exposes its
rows as OptionRows. New GroupedSwitchRow, CustomExpandRow, GroupedHint and
ExpandIcon. Every existing call keeps its behaviour.
This commit is contained in:
2026-10-15 16:50:13 +02:00
parent b1905dac00
commit 762ea5fe57
5 changed files with 204 additions and 45 deletions
@@ -159,6 +159,10 @@ fun GroupedSurface(
* the inter-row gap for a caller that owns its own uniform spacing, e.g.
* [ReorderableColumn]. [onLongClick] (see [GroupedSurface]) adds a long-press to
* the row.
*
* [checked] marks a picker row as the chosen one: it implies [selected] and puts
* [SelectedCheck] in the trailing slot when [trailing] is null — the single-select
* picker row without the `if (selected) { { SelectedCheck() } } else null` dance.
*/
@Composable
fun GroupedRow(
@@ -176,6 +180,7 @@ fun GroupedRow(
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
checked: Boolean = false,
) = GroupedRow(
title = AnnotatedString(title),
position = position,
@@ -191,6 +196,7 @@ fun GroupedRow(
trailing = trailing,
onClick = onClick,
onLongClick = onLongClick,
checked = checked,
)
/**
@@ -215,8 +221,15 @@ fun GroupedRow(
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
checked: Boolean = false,
) {
val itemColors = if (selected) {
val highlighted = selected || checked
val trailingContent = trailing ?: if (checked) {
{ SelectedCheck() }
} else {
null
}
val itemColors = if (highlighted) {
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
@@ -246,7 +259,7 @@ fun GroupedRow(
ListItemDefaults.colors(containerColor = Color.Transparent)
}
val containerColor = when {
selected -> MaterialTheme.colorScheme.secondaryContainer
highlighted -> MaterialTheme.colorScheme.secondaryContainer
container != null -> container
else -> MaterialTheme.colorScheme.surfaceContainerHigh
}
@@ -262,7 +275,7 @@ fun GroupedRow(
headlineContent = { Text(title) },
supportingContent = summary?.let { text -> { Text(text) } },
leadingContent = leading,
trailingContent = trailing,
trailingContent = trailingContent,
colors = itemColors,
modifier = Modifier.heightIn(min = minHeight),
)
@@ -0,0 +1,130 @@
package de.jeanlucmakiola.floret.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
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.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
/**
* A [GroupedRow] with a trailing [Switch], where a tap anywhere on the row flips
* it — the family's settings toggle. [enabled] false dims the row and disables
* both the row and the switch.
*/
@Composable
fun GroupedSwitchRow(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
position: Position,
modifier: Modifier = Modifier,
summary: String? = null,
enabled: Boolean = true,
leading: @Composable (() -> Unit)? = null,
) {
GroupedRow(
title = title,
position = position,
modifier = modifier,
summary = summary,
dimmed = !enabled,
leading = leading,
trailing = {
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
},
onClick = if (enabled) ({ onCheckedChange(!checked) }) else null,
)
}
/**
* The "Custom…" row that closes a presets-plus-custom picker: a [GroupedRow] that
* toggles [expanded] on tap and reveals [editor] beneath it (typically a
* [CustomAmountEditor]). Expanded, the row takes [Position.Top] so it and the
* editor read as one container; collapsed it sits at [position].
*
* [checked] marks the custom value as the current choice, as on the preset rows;
* the caller names it through [title]/[summary].
*/
@Composable
fun CustomExpandRow(
title: String,
position: Position,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
checked: Boolean = false,
editor: @Composable () -> Unit,
) {
Column(modifier) {
GroupedRow(
title = title,
summary = summary,
position = if (expanded) Position.Top else position,
onClick = { onExpandedChange(!expanded) },
checked = checked,
)
AnimatedVisibility(
visible = expanded,
enter = expandEnter(),
exit = collapseExit(),
) {
editor()
}
}
}
/**
* Muted supporting text outside the cards — under a [GroupedSectionHeader], or
* above a picker's rows — inset to [GroupedListInset] so it shares their edge.
* [style] and [verticalPadding] cover the denser variants (`bodySmall`, 8dp under
* a picker title).
*/
@Composable
fun GroupedHint(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.bodyMedium,
verticalPadding: Dp = 4.dp,
) {
Text(
text = text,
style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier.padding(horizontal = GroupedListInset, vertical = verticalPadding),
)
}
/**
* The open/shut chevron for a row that folds content beneath it; turns over as
* [expanded] changes. Decorative — the row carries the semantics.
*/
@Composable
fun ExpandIcon(
expanded: Boolean,
modifier: Modifier = Modifier,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
val turn by animateFloatAsState(if (expanded) 180f else 0f, label = "expand-icon")
Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
contentDescription = null,
tint = tint,
modifier = modifier.rotate(turn),
)
}
@@ -1,15 +1,11 @@
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
@@ -20,7 +16,6 @@ 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
@@ -74,7 +69,7 @@ fun InstructionSteps(
minHeight = 56.dp,
// No disc: the empty leading slot is what tells the header
// apart from the numbered rows it opens.
trailing = { Chevron(open) },
trailing = { ExpandIcon(open) },
onClick = { open = !open },
)
}
@@ -141,22 +136,5 @@ private fun StepNumber(number: Int) {
}
}
/**
* 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
@@ -105,6 +105,9 @@ fun FullScreenPicker(
* the cheap middle rung of the richness ladder described on [FullScreenPicker]:
* every option shows its effect at once and stays comparable, without the cost
* of a live preview.
*
* [dismissOnSelect] closes the picker on a pick (the default). Pass false for a
* preview picker, which applies at once and stays open (see [FullScreenPicker]).
*/
@Composable
fun <T> OptionPicker(
@@ -118,28 +121,49 @@ fun <T> OptionPicker(
leading: (@Composable (T) -> Unit)? = null,
header: (@Composable ColumnScope.() -> Unit)? = null,
predictiveBack: Boolean = false,
dismissOnSelect: Boolean = true,
) {
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = predictiveBack) {
header?.invoke(this)
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
title = label(option),
summary = summary?.invoke(option),
position = positionOf(index, options.size),
selected = isSelected,
leading = leading?.let { { it(option) } },
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(option)
onDismiss()
},
)
}
OptionRows(
options = options,
selected = selected,
label = label,
onSelect = {
onSelect(it)
if (dismissOnSelect) onDismiss()
},
summary = summary,
leading = leading,
)
}
}
/**
* [OptionPicker]'s option list on its own: one connected run of [GroupedRow]s,
* the current option highlighted and checked. For a picker that composes its own
* [FullScreenPicker] — a preview above the rows, a description between — but
* wants the standard rows underneath. [onSelect] only reports the pick; closing
* the picker is the caller's call.
*/
@Composable
fun <T> OptionRows(
options: List<T>,
selected: T,
label: @Composable (T) -> String,
onSelect: (T) -> Unit,
summary: (@Composable (T) -> String?)? = null,
leading: (@Composable (T) -> Unit)? = null,
) {
options.forEachIndexed { index, option ->
GroupedRow(
title = label(option),
summary = summary?.invoke(option),
position = positionOf(index, options.size),
leading = leading?.let { { it(option) } },
onClick = { onSelect(option) },
checked = option == selected,
)
}
}