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:
+17
-14
@@ -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)
|
||||
|
||||
+114
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user