M8d: expense editor options as fixed rows (D-102)

S-7's account, date, payer and split chips (a wrapping FlowRow whose chips
moved when a value changed) become four fixed full-width rows in a stable
order: Konto, Datum, Betalt av, Delning. `EditorOptionRows` in :core:design
(segmented-group shape, the chips' tonal surface-container-high,
secondary-container while the split section is open; leading 24 dp element,
label column as wide as the widest label, value one line with an ellipsis,
trailing chevron or swap). At >= 1.5x font scale the label sits over the value,
which takes up to two lines and whose two-line height every row keeps.
A Robolectric test lays out S-7 with short and long values at 100 % and 200 %
and asserts identical row bounds. The chip composables stay (S-9, S-19).

S-11: connected buttons get 8 dp side padding so "Household" fits half the
filter bar at 1x.

Goldens re-recorded: every s7_* variant, the S-11 en_light goldens, the
analytics_filter_and_total en_light sheet, and the new editor_option_rows
sheet; oxipng -o 4 --strip safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 21:41:34 +02:00
parent c30a49e841
commit bacbd1896c
6 changed files with 317 additions and 23 deletions
@@ -66,6 +66,7 @@ import se.hagstedt.kvitt.design.component.DescriptionField
import se.hagstedt.kvitt.design.component.DirectionCard
import se.hagstedt.kvitt.design.component.DirectionWords
import se.hagstedt.kvitt.design.component.EditorChipRow
import se.hagstedt.kvitt.design.component.EditorOptionRows
import se.hagstedt.kvitt.design.component.EffectLine
import se.hagstedt.kvitt.design.component.EmptyState
import se.hagstedt.kvitt.design.component.ExactSplitEditor
@@ -110,10 +111,13 @@ import se.hagstedt.kvitt.design.component.TileStyle
import se.hagstedt.kvitt.design.component.Timeline
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.UpdateBanner
import se.hagstedt.kvitt.design.component.accountOption
import se.hagstedt.kvitt.design.component.categoryName
import se.hagstedt.kvitt.design.component.dateOption
import se.hagstedt.kvitt.design.component.defaultModeLabel
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.money
import se.hagstedt.kvitt.design.component.payerOption
import se.hagstedt.kvitt.design.component.quickRowCategories
import se.hagstedt.kvitt.design.component.ratioSpoken
import se.hagstedt.kvitt.design.format.AmountInput
@@ -130,6 +134,7 @@ import se.hagstedt.kvitt.model.UpdateState
import kotlinx.datetime.LocalDate
import androidx.compose.ui.res.stringResource
import se.hagstedt.kvitt.core.design.R
import se.hagstedt.kvitt.design.component.splitOption
import se.hagstedt.kvitt.design.component.styledStringResource
import se.hagstedt.kvitt.design.component.viewerPercentOf
@@ -355,6 +360,28 @@ fun ChipsSheet() {
}
}
/** The S-7 option rows (D-102): short values, then long ones; the rows keep their places. */
@Composable
fun OptionRowsSheet() {
val default = defaultModeLabel(viewerPercentOf(Samples.vardag.defaultShares, Samples.jacob.id))
EditorOptionRows(
listOf(
accountOption("Vardag", 0, {}),
dateOption(w("Datum", "Date"), "2026-10-17", {}),
payerOption(Samples.jacob, Samples.anna, {}),
splitOption(default, open = false, onClick = {}),
),
)
EditorOptionRows(
listOf(
accountOption(Samples.sommarstugan.name, 3, {}),
dateOption(w("Datum", "Date"), "2026-10-14", {}),
payerOption(Samples.annaLong, Samples.jacob, {}),
splitOption(w("Allt på Anna-Karin Lindqvist", "All on Anna-Karin Lindqvist"), open = true, onClick = {}),
),
)
}
@Composable
fun AccountPickerSheet() {
val choices = listOf(Samples.vardagSummary, Samples.husetSummary, Samples.japanSummary, Samples.gotlandSummary).mapIndexed { i, s -> AccountChoice(s, i) }
@@ -731,6 +758,7 @@ val CatalogueSheets: List<Pair<String, @Composable ColumnScope.() -> Unit>> = li
"category_quick_row" to { QuickRowSheet() },
"category_grid" to { CategoryGridSheetContent() },
"editor_chips" to { ChipsSheet() },
"editor_option_rows" to { OptionRowsSheet() },
"account_picker" to { AccountPickerSheet() },
"split_bar_slider_preview" to { SplitBarsSheet() },
"split_section_default" to { SplitSectionDefaultSheet() },
@@ -760,7 +788,7 @@ val CatalogueSheets: List<Pair<String, @Composable ColumnScope.() -> Unit>> = li
/** Sheets that also get a 200 % font-scale golden (Swedish, light): the spec's long states. */
val LargeFontSheets: Set<String> = setOf(
"money_text", "account_card", "ledger_rows", "balance_hero_owing", "balance_hero_long", "account_balance_header", "amount_keypad",
"category_quick_row", "editor_chips", "settle_components", "stacked_bar_chart", "breakdown_list", "analytics_filter_and_total",
"category_quick_row", "editor_chips", "editor_option_rows", "settle_components", "stacked_bar_chart", "breakdown_list", "analytics_filter_and_total",
"navigation_bar", "add_expense_fab", "sync_status_pill", "invite_code",
)
@@ -781,6 +809,7 @@ private fun CataloguePreview(name: String) {
@Preview(name = "Ledger", locale = "sv") @Composable private fun PreviewLedger() = CataloguePreview("ledger_rows")
@Preview(name = "Account cards", locale = "sv") @Composable private fun PreviewAccountCards() = CataloguePreview("account_card")
@Preview(name = "Editor chips", locale = "sv") @Composable private fun PreviewChips() = CataloguePreview("editor_chips")
@Preview(name = "Editor option rows", locale = "sv") @Composable private fun PreviewOptionRows() = CataloguePreview("editor_option_rows")
@Preview(name = "Quick row", locale = "sv") @Composable private fun PreviewQuickRow() = CataloguePreview("category_quick_row")
@Preview(name = "Split ratio", locale = "sv") @Composable private fun PreviewRatio() = CataloguePreview("split_section_ratio")
@Preview(name = "Settle", locale = "sv") @Composable private fun PreviewSettle() = CataloguePreview("settle_components")
@@ -24,7 +24,9 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
@@ -46,14 +48,17 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import se.hagstedt.kvitt.core.design.R
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.format.AmountKey
@@ -423,14 +428,7 @@ fun AccountChip(name: String, colorIndex: Int, onClick: () -> Unit, modifier: Mo
/** The date chip: `event`, "Idag" / "Igår" / "14 okt."; TalkBack "Datum: idag". */
@Composable
fun DateChip(isoDate: String, onClick: () -> Unit, modifier: Modifier = Modifier, keyPrefix: String? = null) {
val clock = LocalKvittClock.current
val today = clock.today
val date = DateFormatter.parseDate(isoDate)
val label = when (date.toEpochDays() - today.toEpochDays()) {
0L -> stringResource(R.string.common_today)
-1L -> stringResource(R.string.common_yesterday)
else -> listDate(isoDate)
}
val label = relativeDate(isoDate)
EditorChip(
label,
stringResource(R.string.common_date_spoken, label.replaceFirstChar { it.lowercaseChar() }),
@@ -472,6 +470,150 @@ fun SplitChip(modeLabel: String, open: Boolean, onClick: () -> Unit, modifier: M
)
}
/** "Idag" / "Igår" / "14 okt." (§9.8 list format) for the date chip and the date option row. */
@Composable
private fun relativeDate(isoDate: String): String {
val today = LocalKvittClock.current.today
return when (DateFormatter.parseDate(isoDate).toEpochDays() - today.toEpochDays()) {
0L -> stringResource(R.string.common_today)
-1L -> stringResource(R.string.common_yesterday)
else -> listDate(isoDate)
}
}
/**
* One row of the editor's options (D-102): a [label] ("Konto"), the [value] ("Vardag"), the [trailing] affordance and a
* 24 dp [leading] element. [spoken] is the row's TalkBack sentence; [selected] tints the row as the open split section.
* Built with [accountOption], [dateOption], [payerOption] and [splitOption]; laid out by [EditorOptionRows].
*/
class EditorOption(
val label: String,
val value: String,
val spoken: String,
val onClick: () -> Unit,
val trailing: KvittIcon,
val selected: Boolean = false,
val leading: @Composable () -> Unit,
)
/** The account row: a 12 dp dot in the account's chart colour, "Konto", the name, `expand_more`; TalkBack "Konto: Vardag". */
@Composable
fun accountOption(name: String, colorIndex: Int, onClick: () -> Unit): EditorOption = EditorOption(
stringResource(R.string.editor_account),
name,
stringResource(R.string.editor_account_spoken, name),
onClick,
KvittIcon.ExpandMore,
) { AccountDot(colorIndex) }
/** The date row: `event`, [label] ("Datum"), "Idag" / "Igår" / "14 okt.", `expand_more`; TalkBack "Datum: idag". */
@Composable
fun dateOption(label: String, isoDate: String, onClick: () -> Unit): EditorOption {
val value = relativeDate(isoDate)
return EditorOption(
label,
value,
stringResource(R.string.common_date_spoken, value.replaceFirstChar { it.lowercaseChar() }),
onClick,
KvittIcon.ExpandMore,
) { KvittIconImage(KvittIcon.Event, null) }
}
/** The payer row: payer avatar 24, "Betalt av", "Du" / "Anna", `swap_horiz`; one tap toggles (AC-8.3). */
@Composable
fun payerOption(payer: User, other: User, onToggle: () -> Unit): EditorOption = EditorOption(
stringResource(R.string.editor_paid_by),
personName(payer),
stringResource(R.string.editor_paid_by_toggle, personNameInSentence(payer), personNameInSentence(other)),
onToggle,
KvittIcon.SwapHoriz,
) { PersonAvatar(payer, KvittDimens.avatarChip) }
/** The split row: `call_split`, "Delning", the mode, expand_more / expand_less; selected while the section is open. */
@Composable
fun splitOption(modeLabel: String, open: Boolean, onClick: () -> Unit): EditorOption {
val label = stringResource(R.string.editor_split)
return EditorOption(
label,
modeLabel,
"$label: ${modeLabel.replaceFirstChar { it.lowercaseChar() }}",
onClick,
if (open) KvittIcon.ExpandLess else KvittIcon.ExpandMore,
selected = open,
) { KvittIconImage(KvittIcon.CallSplit, null) }
}
/**
* The editor's options under the category row (D-102; S-7): one full-width row per option, in the order given (S-7:
* Konto, Datum, Betalt av, Delning), as a segmented group (outer corners 24, inner 8, 3 dp apart) in the chips' tonal
* `surface-container-high` (`secondary-container` when selected). Row: min height 48, padding 8 / 16, gap 12: the
* leading element in a 24 dp box, the label (label-large muted) in a column as wide as the widest label, the value
* (body-large, one line with an ellipsis) filling the rest, the trailing icon at the right edge. At ≥ 1.5× font scale
* the label sits over the value, which takes up to two lines and whose two-line height every row keeps. A row's place and size do not depend on its value, so nothing moves when a
* value changes; each row is one button for TalkBack, tagged `editor_option_<index>`.
*/
@Composable
fun EditorOptionRows(options: List<EditorOption>, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
val density = LocalDensity.current
val labelStyle = MaterialTheme.typography.labelLarge
val valueStyle = MaterialTheme.typography.bodyLarge
val measurer = rememberTextMeasurer()
val labels = options.map { it.label }
val labelWidth = remember(labels, labelStyle, density) {
with(density) { (labels.maxOfOrNull { measurer.measure(it, labelStyle, maxLines = 1, density = density).size.width } ?: 0).toDp() }
}
val large = density.fontScale >= KvittDimens.LARGE_FONT_SCALE
val valueLines = if (large) KvittDimens.OPTION_VALUE_LINES_LARGE else 1
// At 200 % every value keeps room for its two lines, so a value that wraps does not make its row taller.
val valueMinHeight = remember(large, valueStyle, density) {
if (!large) 0.dp else with(density) { measurer.measure(List(valueLines) { "X" }.joinToString("\n"), valueStyle, density = density).size.height.toDp() }
}
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.groupGap)) {
options.forEachIndexed { index, option ->
val shape = KvittTheme.shapes.groupItem(index, options.size)
val content = if (option.selected) scheme.onSecondaryContainer else scheme.onSurface
val muted = if (option.selected) scheme.onSecondaryContainer else scheme.onSurfaceVariant
val tag = editorOptionTag(index)
Row(
Modifier
.fillMaxWidth()
.heightIn(min = KvittTheme.spacing.touchTarget)
.clip(shape)
.background(if (option.selected) scheme.secondaryContainer else scheme.surfaceContainerHigh)
.clickable(role = Role.Button, onClick = option.onClick)
.clearAndSetSemantics { contentDescription = option.spoken; role = Role.Button; testTag = tag }
.padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.s2),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
CompositionLocalProvider(LocalContentColor provides muted) {
Box(Modifier.size(KvittDimens.icon), contentAlignment = Alignment.Center) { option.leading() }
}
val value: @Composable () -> Unit = {
Box(Modifier.heightIn(min = valueMinHeight), contentAlignment = Alignment.CenterStart) {
Text(option.value, style = valueStyle, color = content, maxLines = valueLines, overflow = TextOverflow.Ellipsis)
}
}
if (large) {
// 200 %: the label over the value, so the value has the row's whole width for its two lines.
Column(Modifier.weight(1f)) {
Text(option.label, style = labelStyle, color = muted, maxLines = 1, overflow = TextOverflow.Ellipsis)
value()
}
} else {
Text(option.label, Modifier.width(labelWidth), style = labelStyle, color = muted, maxLines = 1, overflow = TextOverflow.Ellipsis)
Box(Modifier.weight(1f)) { value() }
}
KvittIconImage(option.trailing, null, tint = muted)
}
}
}
}
/** The test tag of row [index] of [EditorOptionRows]. */
fun editorOptionTag(index: Int): String = "editor_option_$index"
/** One account offered by [AccountPicker]: the summary for its wording and its 0-based colour position. */
data class AccountChoice(val summary: AccountSummary, val colorIndex: Int)
@@ -6,6 +6,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxWidth
@@ -183,6 +184,7 @@ fun ConnectedButtonGroup(
inactiveBorderColor = Color.Transparent,
),
border = BorderStroke(KvittDimens.divider, Color.Transparent),
contentPadding = PaddingValues(horizontal = KvittDimens.connectedButtonSide, vertical = KvittTheme.spacing.s2),
icon = {},
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
@@ -132,6 +132,8 @@ object KvittDimens {
val chipEnd = 10.dp
val chipStart = 8.dp
val chipGap = 6.dp
/** Lines of an editor option row's value at ≥ 1.5× font scale (one below, D-102). */
const val OPTION_VALUE_LINES_LARGE = 2
val accountDot = 12.dp
val splitBar = 16.dp
val splitBarThin = 8.dp
@@ -187,6 +189,8 @@ object KvittDimens {
val breakdownBar = 10.dp
val breakdownIndent = 44.dp
val filterButtonMin = 40.dp
/** Side padding inside a connected button: 8, as a chip's start padding, so "Household" fits half the filter bar at 1x. */
val connectedButtonSide = 8.dp
val filterToggleMinWidth = 170.dp
// Scaffolding (§3.11).