diff --git a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt index 29b1b65..e67bd52 100644 --- a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt +++ b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt @@ -66,6 +66,7 @@ import se.hagstedt.kvitt.design.component.DescriptionField import se.hagstedt.kvitt.design.component.DirectionCard import se.hagstedt.kvitt.design.component.DirectionWords import se.hagstedt.kvitt.design.component.EditorChipRow +import se.hagstedt.kvitt.design.component.EditorOptionRows import se.hagstedt.kvitt.design.component.EffectLine import se.hagstedt.kvitt.design.component.EmptyState import se.hagstedt.kvitt.design.component.ExactSplitEditor @@ -110,10 +111,13 @@ import se.hagstedt.kvitt.design.component.TileStyle import se.hagstedt.kvitt.design.component.Timeline import se.hagstedt.kvitt.design.component.TopBarNavigation import se.hagstedt.kvitt.design.component.UpdateBanner +import se.hagstedt.kvitt.design.component.accountOption import se.hagstedt.kvitt.design.component.categoryName +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.quickRowCategories import se.hagstedt.kvitt.design.component.ratioSpoken import se.hagstedt.kvitt.design.format.AmountInput @@ -130,6 +134,7 @@ import se.hagstedt.kvitt.model.UpdateState import kotlinx.datetime.LocalDate import androidx.compose.ui.res.stringResource import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.component.splitOption import se.hagstedt.kvitt.design.component.styledStringResource import se.hagstedt.kvitt.design.component.viewerPercentOf @@ -355,6 +360,28 @@ fun ChipsSheet() { } } +/** The S-7 option rows (D-102): short values, then long ones; the rows keep their places. */ +@Composable +fun OptionRowsSheet() { + val default = defaultModeLabel(viewerPercentOf(Samples.vardag.defaultShares, Samples.jacob.id)) + EditorOptionRows( + listOf( + accountOption("Vardag", 0, {}), + dateOption(w("Datum", "Date"), "2026-10-17", {}), + payerOption(Samples.jacob, Samples.anna, {}), + splitOption(default, open = false, onClick = {}), + ), + ) + EditorOptionRows( + listOf( + accountOption(Samples.sommarstugan.name, 3, {}), + dateOption(w("Datum", "Date"), "2026-10-14", {}), + payerOption(Samples.annaLong, Samples.jacob, {}), + splitOption(w("Allt på Anna-Karin Lindqvist", "All on Anna-Karin Lindqvist"), open = true, onClick = {}), + ), + ) +} + @Composable fun AccountPickerSheet() { val choices = listOf(Samples.vardagSummary, Samples.husetSummary, Samples.japanSummary, Samples.gotlandSummary).mapIndexed { i, s -> AccountChoice(s, i) } @@ -731,6 +758,7 @@ val CatalogueSheets: List Unit>> = li "category_quick_row" to { QuickRowSheet() }, "category_grid" to { CategoryGridSheetContent() }, "editor_chips" to { ChipsSheet() }, + "editor_option_rows" to { OptionRowsSheet() }, "account_picker" to { AccountPickerSheet() }, "split_bar_slider_preview" to { SplitBarsSheet() }, "split_section_default" to { SplitSectionDefaultSheet() }, @@ -760,7 +788,7 @@ val CatalogueSheets: List Unit>> = li /** Sheets that also get a 200 % font-scale golden (Swedish, light): the spec's long states. */ val LargeFontSheets: Set = setOf( "money_text", "account_card", "ledger_rows", "balance_hero_owing", "balance_hero_long", "account_balance_header", "amount_keypad", - "category_quick_row", "editor_chips", "settle_components", "stacked_bar_chart", "breakdown_list", "analytics_filter_and_total", + "category_quick_row", "editor_chips", "editor_option_rows", "settle_components", "stacked_bar_chart", "breakdown_list", "analytics_filter_and_total", "navigation_bar", "add_expense_fab", "sync_status_pill", "invite_code", ) @@ -781,6 +809,7 @@ private fun CataloguePreview(name: String) { @Preview(name = "Ledger", locale = "sv") @Composable private fun PreviewLedger() = CataloguePreview("ledger_rows") @Preview(name = "Account cards", locale = "sv") @Composable private fun PreviewAccountCards() = CataloguePreview("account_card") @Preview(name = "Editor chips", locale = "sv") @Composable private fun PreviewChips() = CataloguePreview("editor_chips") +@Preview(name = "Editor option rows", locale = "sv") @Composable private fun PreviewOptionRows() = CataloguePreview("editor_option_rows") @Preview(name = "Quick row", locale = "sv") @Composable private fun PreviewQuickRow() = CataloguePreview("category_quick_row") @Preview(name = "Split ratio", locale = "sv") @Composable private fun PreviewRatio() = CataloguePreview("split_section_ratio") @Preview(name = "Settle", locale = "sv") @Composable private fun PreviewSettle() = CataloguePreview("settle_components") diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Editor.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Editor.kt index 7d942a4..ba20db7 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Editor.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Editor.kt @@ -24,7 +24,9 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -46,14 +48,17 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import se.hagstedt.kvitt.core.design.R import se.hagstedt.kvitt.design.KvittTheme import se.hagstedt.kvitt.design.format.AmountKey @@ -423,14 +428,7 @@ fun AccountChip(name: String, colorIndex: Int, onClick: () -> Unit, modifier: Mo /** The date chip: `event`, "Idag" / "Igår" / "14 okt."; TalkBack "Datum: idag". */ @Composable fun DateChip(isoDate: String, onClick: () -> Unit, modifier: Modifier = Modifier, keyPrefix: String? = null) { - val clock = LocalKvittClock.current - val today = clock.today - val date = DateFormatter.parseDate(isoDate) - val label = when (date.toEpochDays() - today.toEpochDays()) { - 0L -> stringResource(R.string.common_today) - -1L -> stringResource(R.string.common_yesterday) - else -> listDate(isoDate) - } + val label = relativeDate(isoDate) EditorChip( label, stringResource(R.string.common_date_spoken, label.replaceFirstChar { it.lowercaseChar() }), @@ -472,6 +470,150 @@ fun SplitChip(modeLabel: String, open: Boolean, onClick: () -> Unit, modifier: M ) } +/** "Idag" / "Igår" / "14 okt." (§9.8 list format) for the date chip and the date option row. */ +@Composable +private fun relativeDate(isoDate: String): String { + val today = LocalKvittClock.current.today + return when (DateFormatter.parseDate(isoDate).toEpochDays() - today.toEpochDays()) { + 0L -> stringResource(R.string.common_today) + -1L -> stringResource(R.string.common_yesterday) + else -> listDate(isoDate) + } +} + +/** + * One row of the editor's options (D-102): a [label] ("Konto"), the [value] ("Vardag"), the [trailing] affordance and a + * 24 dp [leading] element. [spoken] is the row's TalkBack sentence; [selected] tints the row as the open split section. + * Built with [accountOption], [dateOption], [payerOption] and [splitOption]; laid out by [EditorOptionRows]. + */ +class EditorOption( + val label: String, + val value: String, + val spoken: String, + val onClick: () -> Unit, + val trailing: KvittIcon, + val selected: Boolean = false, + val leading: @Composable () -> Unit, +) + +/** The account row: a 12 dp dot in the account's chart colour, "Konto", the name, `expand_more`; TalkBack "Konto: Vardag". */ +@Composable +fun accountOption(name: String, colorIndex: Int, onClick: () -> Unit): EditorOption = EditorOption( + stringResource(R.string.editor_account), + name, + stringResource(R.string.editor_account_spoken, name), + onClick, + KvittIcon.ExpandMore, +) { AccountDot(colorIndex) } + +/** The date row: `event`, [label] ("Datum"), "Idag" / "Igår" / "14 okt.", `expand_more`; TalkBack "Datum: idag". */ +@Composable +fun dateOption(label: String, isoDate: String, onClick: () -> Unit): EditorOption { + val value = relativeDate(isoDate) + return EditorOption( + label, + value, + stringResource(R.string.common_date_spoken, value.replaceFirstChar { it.lowercaseChar() }), + onClick, + KvittIcon.ExpandMore, + ) { KvittIconImage(KvittIcon.Event, null) } +} + +/** The payer row: payer avatar 24, "Betalt av", "Du" / "Anna", `swap_horiz`; one tap toggles (AC-8.3). */ +@Composable +fun payerOption(payer: User, other: User, onToggle: () -> Unit): EditorOption = EditorOption( + stringResource(R.string.editor_paid_by), + personName(payer), + stringResource(R.string.editor_paid_by_toggle, personNameInSentence(payer), personNameInSentence(other)), + onToggle, + KvittIcon.SwapHoriz, +) { PersonAvatar(payer, KvittDimens.avatarChip) } + +/** The split row: `call_split`, "Delning", the mode, expand_more / expand_less; selected while the section is open. */ +@Composable +fun splitOption(modeLabel: String, open: Boolean, onClick: () -> Unit): EditorOption { + val label = stringResource(R.string.editor_split) + return EditorOption( + label, + modeLabel, + "$label: ${modeLabel.replaceFirstChar { it.lowercaseChar() }}", + onClick, + if (open) KvittIcon.ExpandLess else KvittIcon.ExpandMore, + selected = open, + ) { KvittIconImage(KvittIcon.CallSplit, null) } +} + +/** + * The editor's options under the category row (D-102; S-7): one full-width row per option, in the order given (S-7: + * Konto, Datum, Betalt av, Delning), as a segmented group (outer corners 24, inner 8, 3 dp apart) in the chips' tonal + * `surface-container-high` (`secondary-container` when selected). Row: min height 48, padding 8 / 16, gap 12: the + * leading element in a 24 dp box, the label (label-large muted) in a column as wide as the widest label, the value + * (body-large, one line with an ellipsis) filling the rest, the trailing icon at the right edge. At ≥ 1.5× font scale + * the label sits over the value, which takes up to two lines and whose two-line height every row keeps. A row's place and size do not depend on its value, so nothing moves when a + * value changes; each row is one button for TalkBack, tagged `editor_option_`. + */ +@Composable +fun EditorOptionRows(options: List, modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + val density = LocalDensity.current + val labelStyle = MaterialTheme.typography.labelLarge + val valueStyle = MaterialTheme.typography.bodyLarge + val measurer = rememberTextMeasurer() + val labels = options.map { it.label } + val labelWidth = remember(labels, labelStyle, density) { + with(density) { (labels.maxOfOrNull { measurer.measure(it, labelStyle, maxLines = 1, density = density).size.width } ?: 0).toDp() } + } + val large = density.fontScale >= KvittDimens.LARGE_FONT_SCALE + val valueLines = if (large) KvittDimens.OPTION_VALUE_LINES_LARGE else 1 + // At 200 % every value keeps room for its two lines, so a value that wraps does not make its row taller. + val valueMinHeight = remember(large, valueStyle, density) { + if (!large) 0.dp else with(density) { measurer.measure(List(valueLines) { "X" }.joinToString("\n"), valueStyle, density = density).size.height.toDp() } + } + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.groupGap)) { + options.forEachIndexed { index, option -> + val shape = KvittTheme.shapes.groupItem(index, options.size) + val content = if (option.selected) scheme.onSecondaryContainer else scheme.onSurface + val muted = if (option.selected) scheme.onSecondaryContainer else scheme.onSurfaceVariant + val tag = editorOptionTag(index) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = KvittTheme.spacing.touchTarget) + .clip(shape) + .background(if (option.selected) scheme.secondaryContainer else scheme.surfaceContainerHigh) + .clickable(role = Role.Button, onClick = option.onClick) + .clearAndSetSemantics { contentDescription = option.spoken; role = Role.Button; testTag = tag } + .padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.s2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + CompositionLocalProvider(LocalContentColor provides muted) { + Box(Modifier.size(KvittDimens.icon), contentAlignment = Alignment.Center) { option.leading() } + } + val value: @Composable () -> Unit = { + Box(Modifier.heightIn(min = valueMinHeight), contentAlignment = Alignment.CenterStart) { + Text(option.value, style = valueStyle, color = content, maxLines = valueLines, overflow = TextOverflow.Ellipsis) + } + } + if (large) { + // 200 %: the label over the value, so the value has the row's whole width for its two lines. + Column(Modifier.weight(1f)) { + Text(option.label, style = labelStyle, color = muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + value() + } + } else { + Text(option.label, Modifier.width(labelWidth), style = labelStyle, color = muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + Box(Modifier.weight(1f)) { value() } + } + KvittIconImage(option.trailing, null, tint = muted) + } + } + } +} + +/** The test tag of row [index] of [EditorOptionRows]. */ +fun editorOptionTag(index: Int): String = "editor_option_$index" + /** One account offered by [AccountPicker]: the summary for its wording and its 0-based colour position. */ data class AccountChoice(val summary: AccountSummary, val colorIndex: Int) diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Fields.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Fields.kt index 0c8e529..04c4f34 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Fields.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Fields.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.fillMaxWidth @@ -183,6 +184,7 @@ fun ConnectedButtonGroup( inactiveBorderColor = Color.Transparent, ), border = BorderStroke(KvittDimens.divider, Color.Transparent), + contentPadding = PaddingValues(horizontal = KvittDimens.connectedButtonSide, vertical = KvittTheme.spacing.s2), icon = {}, ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt index 5332199..bda7feb 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt @@ -132,6 +132,8 @@ object KvittDimens { val chipEnd = 10.dp val chipStart = 8.dp val chipGap = 6.dp + /** Lines of an editor option row's value at ≥ 1.5× font scale (one below, D-102). */ + const val OPTION_VALUE_LINES_LARGE = 2 val accountDot = 12.dp val splitBar = 16.dp val splitBarThin = 8.dp @@ -187,6 +189,8 @@ object KvittDimens { val breakdownBar = 10.dp val breakdownIndent = 44.dp val filterButtonMin = 40.dp + /** Side padding inside a connected button: 8, as a chip's start padding, so "Household" fits half the filter bar at 1x. */ + val connectedButtonSide = 8.dp val filterToggleMinWidth = 170.dp // Scaffolding (§3.11). diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt index ce743df..2c5030b 100644 --- a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt @@ -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) diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/EditorOptionRowsLayoutTest.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/EditorOptionRowsLayoutTest.kt new file mode 100644 index 0000000..aa5daff --- /dev/null +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/EditorOptionRowsLayoutTest.kt @@ -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å "), 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> { + 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 + } +}