M8e: recurring editor options as fixed rows (D-107)

S-19's account, payer and split chips (the wrapping EditorChipRow whose chips
moved when a value changed) become three fixed full-width rows in a stable
order: Konto, Betalt av, Delning, using M8d's EditorOptionRows with
accountOption, payerOption and splitOption (no date row; the schedule stays in
its own "Schema" section). TalkBack sentences unchanged. A Robolectric test
lays out S-19 with short and long values at 100 % and 200 % and asserts
identical row bounds.

The chip composables stay in :core:design: S-9 uses EditorChipRow and
DateChip, and the debug catalogue still shows AccountChip, PayerChip and
SplitChip.

Goldens re-recorded: every s19_recurring_editor_* variant; 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:48:40 +02:00
parent 2476667510
commit a59abebc49
2 changed files with 126 additions and 13 deletions
@@ -44,7 +44,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittElevation
import se.hagstedt.kvitt.design.KvittTheme
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
@@ -53,7 +52,7 @@ import se.hagstedt.kvitt.design.component.CategoryGrid
import se.hagstedt.kvitt.design.component.CategoryQuickRow
import se.hagstedt.kvitt.design.component.ConfirmDialog
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
@@ -62,18 +61,19 @@ import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTextField
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.defaultModeLabel
import se.hagstedt.kvitt.design.component.kvittLanguage
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.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.design.format.DateFormatter
@@ -230,15 +230,18 @@ private fun EditorBody(state: RecurringEditorState, actions: RecurringEditorActi
val payer = if ((form.payer ?: me.id) == me.id) me else partner
val viewerPercent = state.viewerDefaultPercent
val defaultLabel = defaultModeLabel(viewerPercent)
EditorChipRow {
AccountChip(state.account?.name.orEmpty(), state.accountColorIndex, { actions.onOpenSheet(EditorSheet.Account) })
PayerChip(payer, if (payer == me) partner else me, actions.onTogglePayer)
SplitChip(
// Three fixed rows in a stable order (Konto, Betalt av, Delning), so nothing moves when a value changes (D-107).
EditorOptionRows(
listOf(
accountOption(state.account?.name.orEmpty(), state.accountColorIndex) { actions.onOpenSheet(EditorSheet.Account) },
payerOption(payer, if (payer == me) partner else me, actions.onTogglePayer),
splitOption(
if (form.mode == SplitMode.RATIO) stringResource(DesignR.string.editor_split_mode_ratio) + " " + stringResource(DesignR.string.common_ratio, (form.viewerBp / 100).toString(), ((10_000 - form.viewerBp) / 100).toString()) else splitModeLabel(form.mode, defaultLabel),
form.splitOpen,
actions.onToggleSplit,
),
),
)
}
if (form.splitOpen) {
SplitSection(form.mode, defaultLabel, actions.onMode, actions.onToggleSplit) {
when (form.mode) {
@@ -0,0 +1,110 @@
package se.hagstedt.kvitt.feature.settings.recurring
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.fakes.settings.ScriptedSettingsRepositories
import se.hagstedt.kvitt.fakes.settings.SettingsScenarios
import se.hagstedt.kvitt.feature.settings.screenshot.ScreenSnapshots
import se.hagstedt.kvitt.model.SplitMode
/**
* The three option rows of S-19 (Konto, Betalt av, Delning; D-107, extending D-102) keep their places and sizes when
* their values change: the editor is laid out with the short values of "Hyra", then with long ones (a long account
* name, 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-h914dp-mdpi")
class RecurringOptionRowsLayoutTest {
@get:Rule
val compose = createComposeRule()
private val short: RecurringEditorState by lazy {
val r = ScriptedSettingsRepositories(SettingsScenarios.loaded)
val f = r.fakes
val handle = SavedStateHandle(mapOf(RecurringEditorViewModel.ARG_TEMPLATE_ID to SettingsScenarios.hyra.id.value))
val vm = RecurringEditorViewModel(handle, f.expenses, f.home, r.recurring, f.connectivity)
ScreenSnapshots.idle()
requireNotNull(vm.state.value)
}
private fun long(state: RecurringEditorState): RecurringEditorState {
val partner = state.options.partner.copy(displayName = "Anna-Karin Lindqvist Bergström")
return state.copy(
options = state.options.copy(partner = partner),
account = requireNotNull(state.account).copy(name = "Sommarstugan i Österåker med bryggan, bastun och båthuset"),
form = state.form.copy(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 { RecurringEditorContent(state, RecurringEditorActions()) }
}
}
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)
// Three 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) }
compose.onNodeWithTag(editorOptionTag(ROWS)).assertDoesNotExist()
}
@Test
fun rowsStayPutWhenValuesChange() = assertStable(1f)
@Test
fun rowsStayPutWhenValuesChangeAt200Percent() = assertStable(2f)
private companion object {
const val ROWS = 3
const val MIN_ROW_DP = 48f
}
}