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
@@ -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
}
}