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).
@@ -56,7 +56,6 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.design.component.AccountChip
import se.hagstedt.kvitt.design.component.AccountPicker
import se.hagstedt.kvitt.design.component.AmountDisplay
import se.hagstedt.kvitt.design.component.AmountKeypad
@@ -66,27 +65,28 @@ import se.hagstedt.kvitt.design.component.CategoryQuickRow
import se.hagstedt.kvitt.design.component.CompactEditorSummary
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.ConfirmDialogContent
import se.hagstedt.kvitt.design.component.DateChip
import se.hagstedt.kvitt.design.component.DescriptionField
import se.hagstedt.kvitt.design.component.EditorChipRow
import se.hagstedt.kvitt.design.component.EditorOptionRows
import se.hagstedt.kvitt.design.component.ExactSplitEditor
import se.hagstedt.kvitt.design.component.FullSplitPicker
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NotesField
import se.hagstedt.kvitt.design.component.PayerChip
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.RatioEditor
import se.hagstedt.kvitt.design.component.RatioField
import se.hagstedt.kvitt.design.component.SplitBar
import se.hagstedt.kvitt.design.component.SplitChip
import se.hagstedt.kvitt.design.component.SplitPreview
import se.hagstedt.kvitt.design.component.SplitSection
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.accountOption
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.splitModeLabel
import se.hagstedt.kvitt.design.component.splitOption
import se.hagstedt.kvitt.design.component.viewerPercentOf
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
@@ -237,13 +237,16 @@ private fun EditorBody(state: ExpenseEditorState, actions: ExpenseEditorActions,
modifier = Modifier.focusRequester(descriptionFocus).onFocusChanged { if (it.hasFocus) actions.focusKeypad(null) },
)
CategoryQuickRow(state.quickRow, form.categoryId, { actions.onCategory(it.id) }, { actions.showOverlay(EditorOverlay.CategoryGrid) })
EditorChipRow {
state.account?.let { AccountChip(it.name, state.accountColorIndex, { actions.showOverlay(EditorOverlay.AccountPicker) }) }
DateChip(form.date, { actions.showOverlay(EditorOverlay.DatePicker) })
val payer = if (form.payer == me.id) me else partner
PayerChip(payer, if (payer == me) partner else me, actions::togglePayer)
SplitChip(modeChipLabel(state), state.splitOpen, actions::toggleSplit)
}
// Four fixed rows in a stable order, so nothing moves when a value changes (D-102).
val payer = if (form.payer == me.id) me else partner
EditorOptionRows(
listOfNotNull(
state.account?.let { accountOption(it.name, state.accountColorIndex) { actions.showOverlay(EditorOverlay.AccountPicker) } },
dateOption(stringResource(R.string.expense_editor_date), form.date) { actions.showOverlay(EditorOverlay.DatePicker) },
payerOption(payer, if (payer == me) partner else me, actions::togglePayer),
splitOption(modeOptionLabel(state), state.splitOpen, actions::toggleSplit),
),
)
errorText(state.errors, ExpenseEditorViewModel.FIELD_DATE)?.let { ErrorLine(it) }
if (!state.splitOpen) {
SplitPreview(state.preview, me, partner)
@@ -441,9 +444,9 @@ private fun accountViewerPercent(state: ExpenseEditorState): Int {
@Composable
private fun defaultLabel(state: ExpenseEditorState): String = defaultModeLabel(accountViewerPercent(state))
/** The Delning chip: "Kontots standard 50/50", "Egen fördelning 70/30", "Exakta belopp", "Allt på Anna" (design-spec §3.7). */
/** The Delning row's value: "Kontots standard 50/50", "Egen fördelning 70/30", "Exakta belopp", "Allt på Anna" (design-spec §3.7). */
@Composable
private fun modeChipLabel(state: ExpenseEditorState): String {
private fun modeOptionLabel(state: ExpenseEditorState): String {
val form = state.form ?: return ""
return when (form.mode) {
SplitMode.DEFAULT -> defaultLabel(state)
@@ -0,0 +1,114 @@
package se.hagstedt.kvitt.feature.expense
import android.content.res.Configuration
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpRect
import androidx.lifecycle.SavedStateHandle
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.editorOptionTag
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
import se.hagstedt.kvitt.feature.expense.screenshot.NoActions
import se.hagstedt.kvitt.feature.expense.screenshot.ScreenSnapshots
import se.hagstedt.kvitt.model.SplitMode
/**
* The four option rows of S-7 (Konto, Datum, Betalt av, Delning; D-102) keep their places and sizes when their values
* change: the editor is laid out with short values, then with long ones (a long account name, a date that is not today
* or yesterday, the partner with a long name as payer, "Allt på <long name>"), and every row's bounds must be identical,
* at 100 % and at 200 % font scale. Native graphics: only real text layout makes a long value wrap.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w411dp-h891dp-mdpi")
class EditorOptionRowsLayoutTest {
@get:Rule
val compose = createComposeRule()
private val short: ExpenseEditorState = ScreenSnapshots.build { idle ->
val fakes = FakeRepositories(ExpenseScenarios.household)
val vm = ExpenseEditorViewModel(SavedStateHandle(), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity)
idle()
"412".forEach { vm.onKey(AmountKey.Digit(it.digitToInt())) }
vm.onDescription("Systembolaget")
idle()
vm.focusKeypad(null)
vm.state.value
}
private fun long(state: ExpenseEditorState): ExpenseEditorState {
val partner = requireNotNull(state.partner).copy(displayName = "Anna-Karin Lindqvist Bergström")
val form = requireNotNull(state.form)
return state.copy(
partner = partner,
account = requireNotNull(state.account).copy(name = "Sommarstugan i Österåker med bryggan, bastun och båthuset"),
form = form.copy(date = "2026-09-14", payer = partner.id, mode = SplitMode.FULL, fullOn = partner.id),
)
}
private fun rowBounds(fontScale: Float): Pair<List<DpRect>, List<DpRect>> {
var state by mutableStateOf(short)
compose.setContent {
val base = LocalContext.current
val baseConfiguration = LocalConfiguration.current
val context = remember { base.createConfigurationContext(Configuration(baseConfiguration).apply { this.fontScale = fontScale }) }
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
LocalResources provides context.resources,
LocalDensity provides Density(context),
LocalKvittClock provides ScreenSnapshots.clock,
) {
KvittTheme { ExpenseEditorContent(state, NoActions) }
}
}
fun bounds() = (0 until ROWS).map { compose.onNodeWithTag(editorOptionTag(it)).getUnclippedBoundsInRoot() }
compose.waitForIdle()
val before = bounds()
state = long(short)
compose.waitForIdle()
return before to bounds()
}
private fun assertStable(fontScale: Float) {
val (before, after) = rowBounds(fontScale)
assertEquals(before, after)
// Four rows, top to bottom, full width, each at least the 48 dp touch target.
before.zipWithNext { a, b -> assertTrue("rows in order: $a above $b", a.bottom <= b.top) }
assertEquals(1, before.map { it.left to it.right }.distinct().size)
before.forEach { assertTrue("row height of $it", (it.bottom - it.top).value >= MIN_ROW_DP) }
}
@Test
fun rowsStayPutWhenValuesChange() = assertStable(1f)
@Test
fun rowsStayPutWhenValuesChangeAt200Percent() = assertStable(2f)
private companion object {
const val ROWS = 4
const val MIN_ROW_DP = 48f
}
}