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.DirectionCard
|
||||||
import se.hagstedt.kvitt.design.component.DirectionWords
|
import se.hagstedt.kvitt.design.component.DirectionWords
|
||||||
import se.hagstedt.kvitt.design.component.EditorChipRow
|
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.EffectLine
|
||||||
import se.hagstedt.kvitt.design.component.EmptyState
|
import se.hagstedt.kvitt.design.component.EmptyState
|
||||||
import se.hagstedt.kvitt.design.component.ExactSplitEditor
|
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.Timeline
|
||||||
import se.hagstedt.kvitt.design.component.TopBarNavigation
|
import se.hagstedt.kvitt.design.component.TopBarNavigation
|
||||||
import se.hagstedt.kvitt.design.component.UpdateBanner
|
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.categoryName
|
||||||
|
import se.hagstedt.kvitt.design.component.dateOption
|
||||||
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.money
|
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.quickRowCategories
|
||||||
import se.hagstedt.kvitt.design.component.ratioSpoken
|
import se.hagstedt.kvitt.design.component.ratioSpoken
|
||||||
import se.hagstedt.kvitt.design.format.AmountInput
|
import se.hagstedt.kvitt.design.format.AmountInput
|
||||||
@@ -130,6 +134,7 @@ import se.hagstedt.kvitt.model.UpdateState
|
|||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import se.hagstedt.kvitt.core.design.R
|
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.styledStringResource
|
||||||
import se.hagstedt.kvitt.design.component.viewerPercentOf
|
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
|
@Composable
|
||||||
fun AccountPickerSheet() {
|
fun AccountPickerSheet() {
|
||||||
val choices = listOf(Samples.vardagSummary, Samples.husetSummary, Samples.japanSummary, Samples.gotlandSummary).mapIndexed { i, s -> AccountChoice(s, i) }
|
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_quick_row" to { QuickRowSheet() },
|
||||||
"category_grid" to { CategoryGridSheetContent() },
|
"category_grid" to { CategoryGridSheetContent() },
|
||||||
"editor_chips" to { ChipsSheet() },
|
"editor_chips" to { ChipsSheet() },
|
||||||
|
"editor_option_rows" to { OptionRowsSheet() },
|
||||||
"account_picker" to { AccountPickerSheet() },
|
"account_picker" to { AccountPickerSheet() },
|
||||||
"split_bar_slider_preview" to { SplitBarsSheet() },
|
"split_bar_slider_preview" to { SplitBarsSheet() },
|
||||||
"split_section_default" to { SplitSectionDefaultSheet() },
|
"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. */
|
/** Sheets that also get a 200 % font-scale golden (Swedish, light): the spec's long states. */
|
||||||
val LargeFontSheets: Set<String> = setOf(
|
val LargeFontSheets: Set<String> = setOf(
|
||||||
"money_text", "account_card", "ledger_rows", "balance_hero_owing", "balance_hero_long", "account_balance_header", "amount_keypad",
|
"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",
|
"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 = "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 = "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 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 = "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 = "Split ratio", locale = "sv") @Composable private fun PreviewRatio() = CataloguePreview("split_section_ratio")
|
||||||
@Preview(name = "Settle", locale = "sv") @Composable private fun PreviewSettle() = CataloguePreview("settle_components")
|
@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.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.customActions
|
||||||
import androidx.compose.ui.semantics.role
|
import androidx.compose.ui.semantics.role
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.testTag
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import se.hagstedt.kvitt.core.design.R
|
import se.hagstedt.kvitt.core.design.R
|
||||||
import se.hagstedt.kvitt.design.KvittTheme
|
import se.hagstedt.kvitt.design.KvittTheme
|
||||||
import se.hagstedt.kvitt.design.format.AmountKey
|
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". */
|
/** The date chip: `event`, "Idag" / "Igår" / "14 okt."; TalkBack "Datum: idag". */
|
||||||
@Composable
|
@Composable
|
||||||
fun DateChip(isoDate: String, onClick: () -> Unit, modifier: Modifier = Modifier, keyPrefix: String? = null) {
|
fun DateChip(isoDate: String, onClick: () -> Unit, modifier: Modifier = Modifier, keyPrefix: String? = null) {
|
||||||
val clock = LocalKvittClock.current
|
val label = relativeDate(isoDate)
|
||||||
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)
|
|
||||||
}
|
|
||||||
EditorChip(
|
EditorChip(
|
||||||
label,
|
label,
|
||||||
stringResource(R.string.common_date_spoken, label.replaceFirstChar { it.lowercaseChar() }),
|
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. */
|
/** 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)
|
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.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -183,6 +184,7 @@ fun ConnectedButtonGroup(
|
|||||||
inactiveBorderColor = Color.Transparent,
|
inactiveBorderColor = Color.Transparent,
|
||||||
),
|
),
|
||||||
border = BorderStroke(KvittDimens.divider, Color.Transparent),
|
border = BorderStroke(KvittDimens.divider, Color.Transparent),
|
||||||
|
contentPadding = PaddingValues(horizontal = KvittDimens.connectedButtonSide, vertical = KvittTheme.spacing.s2),
|
||||||
icon = {},
|
icon = {},
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ object KvittDimens {
|
|||||||
val chipEnd = 10.dp
|
val chipEnd = 10.dp
|
||||||
val chipStart = 8.dp
|
val chipStart = 8.dp
|
||||||
val chipGap = 6.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 accountDot = 12.dp
|
||||||
val splitBar = 16.dp
|
val splitBar = 16.dp
|
||||||
val splitBarThin = 8.dp
|
val splitBarThin = 8.dp
|
||||||
@@ -187,6 +189,8 @@ object KvittDimens {
|
|||||||
val breakdownBar = 10.dp
|
val breakdownBar = 10.dp
|
||||||
val breakdownIndent = 44.dp
|
val breakdownIndent = 44.dp
|
||||||
val filterButtonMin = 40.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
|
val filterToggleMinWidth = 170.dp
|
||||||
|
|
||||||
// Scaffolding (§3.11).
|
// Scaffolding (§3.11).
|
||||||
|
|||||||
+16
-13
@@ -56,7 +56,6 @@ import kotlinx.datetime.LocalDate
|
|||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import se.hagstedt.kvitt.design.KvittTheme
|
import se.hagstedt.kvitt.design.KvittTheme
|
||||||
import se.hagstedt.kvitt.core.design.R as DesignR
|
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.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
|
||||||
@@ -66,27 +65,28 @@ import se.hagstedt.kvitt.design.component.CategoryQuickRow
|
|||||||
import se.hagstedt.kvitt.design.component.CompactEditorSummary
|
import se.hagstedt.kvitt.design.component.CompactEditorSummary
|
||||||
import se.hagstedt.kvitt.design.component.ConfirmDialog
|
import se.hagstedt.kvitt.design.component.ConfirmDialog
|
||||||
import se.hagstedt.kvitt.design.component.ConfirmDialogContent
|
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.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
|
||||||
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.dateOption
|
||||||
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.money
|
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.splitModeLabel
|
||||||
|
import se.hagstedt.kvitt.design.component.splitOption
|
||||||
import se.hagstedt.kvitt.design.component.viewerPercentOf
|
import se.hagstedt.kvitt.design.component.viewerPercentOf
|
||||||
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
|
||||||
@@ -237,13 +237,16 @@ private fun EditorBody(state: ExpenseEditorState, actions: ExpenseEditorActions,
|
|||||||
modifier = Modifier.focusRequester(descriptionFocus).onFocusChanged { if (it.hasFocus) actions.focusKeypad(null) },
|
modifier = Modifier.focusRequester(descriptionFocus).onFocusChanged { if (it.hasFocus) actions.focusKeypad(null) },
|
||||||
)
|
)
|
||||||
CategoryQuickRow(state.quickRow, form.categoryId, { actions.onCategory(it.id) }, { actions.showOverlay(EditorOverlay.CategoryGrid) })
|
CategoryQuickRow(state.quickRow, form.categoryId, { actions.onCategory(it.id) }, { actions.showOverlay(EditorOverlay.CategoryGrid) })
|
||||||
EditorChipRow {
|
// Four fixed rows in a stable order, so nothing moves when a value changes (D-102).
|
||||||
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
|
val payer = if (form.payer == me.id) me else partner
|
||||||
PayerChip(payer, if (payer == me) partner else me, actions::togglePayer)
|
EditorOptionRows(
|
||||||
SplitChip(modeChipLabel(state), state.splitOpen, actions::toggleSplit)
|
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) }
|
errorText(state.errors, ExpenseEditorViewModel.FIELD_DATE)?.let { ErrorLine(it) }
|
||||||
if (!state.splitOpen) {
|
if (!state.splitOpen) {
|
||||||
SplitPreview(state.preview, me, partner)
|
SplitPreview(state.preview, me, partner)
|
||||||
@@ -441,9 +444,9 @@ private fun accountViewerPercent(state: ExpenseEditorState): Int {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun defaultLabel(state: ExpenseEditorState): String = defaultModeLabel(accountViewerPercent(state))
|
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
|
@Composable
|
||||||
private fun modeChipLabel(state: ExpenseEditorState): String {
|
private fun modeOptionLabel(state: ExpenseEditorState): String {
|
||||||
val form = state.form ?: return ""
|
val form = state.form ?: return ""
|
||||||
return when (form.mode) {
|
return when (form.mode) {
|
||||||
SplitMode.DEFAULT -> defaultLabel(state)
|
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