diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt index 69951c3..075548a 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt @@ -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( - 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, - ) - } + // 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) { diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringOptionRowsLayoutTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringOptionRowsLayoutTest.kt new file mode 100644 index 0000000..dd9a335 --- /dev/null +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringOptionRowsLayoutTest.kt @@ -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å "), 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> { + 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 + } +}