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).
|
||||
|
||||
Reference in New Issue
Block a user