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:
+30
-1
@@ -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<Pair<String, @Composable ColumnScope.() -> 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<Pair<String, @Composable ColumnScope.() -> Unit>> = li
|
||||
/** Sheets that also get a 200 % font-scale golden (Swedish, light): the spec's long states. */
|
||||
val LargeFontSheets: Set<String> = 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")
|
||||
|
||||
@@ -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_<index>`.
|
||||
*/
|
||||
@Composable
|
||||
fun EditorOptionRows(options: List<EditorOption>, 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)
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
+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