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:
+12
-9
@@ -44,7 +44,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import se.hagstedt.kvitt.design.KvittElevation
|
import se.hagstedt.kvitt.design.KvittElevation
|
||||||
import se.hagstedt.kvitt.design.KvittTheme
|
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.AccountPicker
|
||||||
import se.hagstedt.kvitt.design.component.AmountDisplay
|
import se.hagstedt.kvitt.design.component.AmountDisplay
|
||||||
import se.hagstedt.kvitt.design.component.AmountKeypad
|
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.CategoryQuickRow
|
||||||
import se.hagstedt.kvitt.design.component.ConfirmDialog
|
import se.hagstedt.kvitt.design.component.ConfirmDialog
|
||||||
import se.hagstedt.kvitt.design.component.DescriptionField
|
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.ExactSplitEditor
|
||||||
import se.hagstedt.kvitt.design.component.FullSplitPicker
|
import se.hagstedt.kvitt.design.component.FullSplitPicker
|
||||||
import se.hagstedt.kvitt.design.component.KvittButton
|
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.KvittTextField
|
||||||
import se.hagstedt.kvitt.design.component.KvittTopAppBar
|
import se.hagstedt.kvitt.design.component.KvittTopAppBar
|
||||||
import se.hagstedt.kvitt.design.component.NotesField
|
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.ProvideHousehold
|
||||||
import se.hagstedt.kvitt.design.component.RatioEditor
|
import se.hagstedt.kvitt.design.component.RatioEditor
|
||||||
import se.hagstedt.kvitt.design.component.RatioField
|
import se.hagstedt.kvitt.design.component.RatioField
|
||||||
import se.hagstedt.kvitt.design.component.SplitBar
|
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.SplitPreview
|
||||||
import se.hagstedt.kvitt.design.component.SplitSection
|
import se.hagstedt.kvitt.design.component.SplitSection
|
||||||
import se.hagstedt.kvitt.design.component.TopBarNavigation
|
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.defaultModeLabel
|
||||||
import se.hagstedt.kvitt.design.component.kvittLanguage
|
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.splitModeLabel
|
||||||
|
import se.hagstedt.kvitt.design.component.splitOption
|
||||||
import se.hagstedt.kvitt.design.format.AmountInput
|
import se.hagstedt.kvitt.design.format.AmountInput
|
||||||
import se.hagstedt.kvitt.design.format.AmountKey
|
import se.hagstedt.kvitt.design.format.AmountKey
|
||||||
import se.hagstedt.kvitt.design.format.DateFormatter
|
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 payer = if ((form.payer ?: me.id) == me.id) me else partner
|
||||||
val viewerPercent = state.viewerDefaultPercent
|
val viewerPercent = state.viewerDefaultPercent
|
||||||
val defaultLabel = defaultModeLabel(viewerPercent)
|
val defaultLabel = defaultModeLabel(viewerPercent)
|
||||||
EditorChipRow {
|
// Three fixed rows in a stable order (Konto, Betalt av, Delning), so nothing moves when a value changes (D-107).
|
||||||
AccountChip(state.account?.name.orEmpty(), state.accountColorIndex, { actions.onOpenSheet(EditorSheet.Account) })
|
EditorOptionRows(
|
||||||
PayerChip(payer, if (payer == me) partner else me, actions.onTogglePayer)
|
listOf(
|
||||||
SplitChip(
|
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),
|
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,
|
form.splitOpen,
|
||||||
actions.onToggleSplit,
|
actions.onToggleSplit,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if (form.splitOpen) {
|
if (form.splitOpen) {
|
||||||
SplitSection(form.mode, defaultLabel, actions.onMode, actions.onToggleSplit) {
|
SplitSection(form.mode, defaultLabel, actions.onMode, actions.onToggleSplit) {
|
||||||
when (form.mode) {
|
when (form.mode) {
|
||||||
|
|||||||
+110
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user