M6b: core:design fixes after the M7 wave

Bugs: viewerPercentOf reads basis points (Samples in bp); FullSplitPicker and the
split labels use editor_split_full_on_you ("Allt på dig"); AmountInput.of restores
money with both decimals (AmountInput.percent for ratio fields), replacing M7c's
moneyInput; quickRowCategories does the D-060 fill itself; KvittScaffold resolves
resource-backed snackbars (showKvittSnackbar) while shown.

Gaps: ConfirmDialog optional and styled body; KvittButton loading form (M7a uses it,
ProgressBottomButton removed); InviteCodeField wraps after a group, placeholder,
imeAction; EmptyState icon tile and actionEnabled; DateFormatter.monthName and
dayRangeLong; AnalyticsFilterBar periodOwnRow; segmentedItem builder; screen tokens
in KvittDimens (OnboardingDimens removed); common_ok, common_undo; module-proposed
keys recorded in strings.csv; quick-row labels end in an ellipsis on line two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 11:35:06 +02:00
parent c4ab35f66e
commit b24c8b5ba3
49 changed files with 655 additions and 261 deletions
@@ -127,6 +127,10 @@ import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
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.styledStringResource
import se.hagstedt.kvitt.design.component.viewerPercentOf
// The preview catalogue: one sheet per component of docs/ux/design-spec.md §3, with its states. The screenshot tests
// render exactly these sheets, so a preview and its golden never drift apart.
@@ -319,10 +323,14 @@ fun TextFieldSheet() {
@Composable
fun QuickRowSheet() {
// No usage yet (D-060): the first four in sort_order and the selected one fifth.
val row = quickRowCategories(Samples.defaults, emptyList(), Samples.entertainment.id)
CategoryQuickRow(row, Samples.entertainment.id, {}, {})
val frequent = quickRowCategories(Samples.defaults, listOf(Samples.groceries.id, Samples.restaurants.id, Samples.home.id, Samples.transport.id, Samples.health.id), Samples.shopping.id)
CategoryQuickRow(frequent, Samples.shopping.id, {}, {})
// A long custom name: two lines, ellipsis on the second, no hyphenation (D-081).
val custom = Samples.defaults + Samples.renovation
CategoryQuickRow(quickRowCategories(custom, listOf(Samples.renovation.id, Samples.groceries.id), Samples.groceries.id), Samples.groceries.id, {}, {})
}
@Composable
@@ -336,7 +344,7 @@ fun ChipsSheet() {
AccountChip("Vardag", 0, {})
DateChip("2026-10-17", {})
PayerChip(Samples.jacob, Samples.anna, {})
SplitChip(defaultModeLabel(50), open = false, onClick = {})
SplitChip(defaultModeLabel(viewerPercentOf(Samples.vardag.defaultShares, Samples.jacob.id)), open = false, onClick = {})
}
EditorChipRow {
DateChip("2026-10-16", {})
@@ -522,6 +530,14 @@ fun FilterSheet() {
FilterToggle(w("Gruppera efter", "Group by"), listOf(w("Kategori", "Category"), w("Konto", "Account")), 0, {}),
)
TotalCard(17_821_140, w("Totalt", "Total"), w("maj–okt 2026 · Hushållet", "May–Oct 2026 · Household"))
// A custom range: the period chip on its own row, so the label is not cut (design frame S-11-analytics-week).
AnalyticsFilterBar(
w("Anpassad · 24 aug.–17 okt.", "Custom · 24 Aug–17 Oct"), w("Period: anpassad, 24 augusti–17 oktober", "Period: custom, 24 August–17 October"), {},
FilterToggle(w("Indelning", "Interval"), listOf(w("Månad", "Month"), w("Vecka", "Week")), 1, {}),
FilterToggle(w("Mått", "Measure"), listOf(w("Hushållet", "Household"), w("Min del", "My share")), 1, {}),
FilterToggle(w("Gruppera efter", "Group by"), listOf(w("Kategori", "Category"), w("Konto", "Account")), 1, {}),
periodOwnRow = true,
)
}
@Composable
@@ -568,6 +584,11 @@ fun ButtonSheet() {
}
KvittButton(w("Bekräfta", "Confirm"), {}, Modifier.fillMaxWidth(), size = ButtonSize.Medium, icon = KvittIcon.Check)
KvittButton(w("Para ihop", "Pair"), {}, Modifier.fillMaxWidth(), size = ButtonSize.Medium, icon = KvittIcon.Link, enabled = false)
KvittButton(w("Parar ihop…", "Pairing…"), {}, Modifier.fillMaxWidth(), size = ButtonSize.Medium, icon = KvittIcon.Link, loading = true)
Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
KvittButton(stringResource(R.string.common_save), {}, loading = true)
KvittButton(w("Synka nu", "Sync now"), {}, kind = ButtonKind.Tonal, icon = KvittIcon.Sync, loading = true)
}
}
@Composable
@@ -623,12 +644,24 @@ fun EmptyStateSheet() {
illustration = listOf(Samples.other, Samples.other, Samples.other), illustrationContainer = true,
action = w("Byt period", "Change period"), actionIcon = KvittIcon.DateRange, actionKind = ButtonKind.Tonal,
)
// S-8 / S-10 missing, offline: the icon tile and the online-only action drawn disabled with the hint.
EmptyState(
w("Utgiften finns inte på telefonen än", "This expense is not on the phone yet"),
w("Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den.", "It may have just been added on another phone. Sync to fetch it."),
icon = KvittIcon.SyncProblem,
action = w("Synka nu", "Sync now"), actionIcon = KvittIcon.Sync, actionKind = ButtonKind.Tonal, actionEnabled = false,
)
}
@Composable
fun DialogSheet() {
ConfirmDialogContent(KvittIcon.Delete, w("Ta bort utgiften?", "Delete the expense?"), w("Den försvinner för er båda.", "It disappears for both of you."), w("Ta bort", "Delete"), w("Avbryt", "Cancel"), {}, {}, destructive = true)
ConfirmDialogContent(KvittIcon.InstallMobile, w("Tillåt installation", "Allow installation"), w("Kvitt uppdateras direkt från er server, inte från Play Butik.", "Kvitt updates straight from your own server, not from the Play Store."), w("Fortsätt", "Continue"), w("Senare", "Later"), {}, {}, tertiaryTile = true, confirmIcon = KvittIcon.ArrowForward)
ConfirmDialogContent(
KvittIcon.InstallMobile, stringResource(R.string.update_permission_title), null, stringResource(R.string.common_continue), stringResource(R.string.update_later), {}, {},
tertiaryTile = true, confirmIcon = KvittIcon.ArrowForward, styledBody = styledStringResource(R.string.update_permission_body),
)
// No body: a discard question (S-6, S-7, S-9, S-19).
ConfirmDialogContent(KvittIcon.Close, stringResource(R.string.common_discard_title), null, stringResource(R.string.common_discard), stringResource(R.string.common_keep_editing), {}, {})
}
@Composable
@@ -647,6 +680,7 @@ fun MenuSheet() {
@Composable
fun InviteSheet() {
InviteCodeDisplay("7K3MQX9D2HPA")
InviteCodeField("", {}, w("Klistra in", "Paste"), {}, placeholder = "XXXX-XXXX-XXXX")
InviteCodeField("7K3MQX9D2HPA", {}, w("Klistra in", "Paste"), {})
InviteCodeField("7k3m-qx9", {}, w("Klistra in", "Paste"), {}, error = w("Koden fungerar inte. Den kan ha gått ut eller redan använts. Be om en ny.", "The code does not work. It may have expired or been used. Ask for a new one."))
}
@@ -721,7 +755,7 @@ val CatalogueSheets: List<Pair<String, @Composable ColumnScope.() -> Unit>> = li
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",
"navigation_bar", "add_expense_fab", "sync_status_pill",
"navigation_bar", "add_expense_fab", "sync_status_pill", "invite_code",
)
@Composable
@@ -58,15 +58,18 @@ object Samples {
val other = category("00000000000a", DefaultCategoryKey.OTHER, "category", ColorToken.GREY, 100)
val pant = Category(CategoryId("01920000-0000-7000-8000-0000000000c1"), null, "Pant", "water_drop", ColorToken.TEAL, 110, archived = true)
val renamedGroceries = groceries.copy(customName = "Matvaror")
/** A long custom name: two lines with an ellipsis in the quick row (D-081). */
val renovation = Category(CategoryId("01920000-0000-7000-8000-0000000000c2"), null, "Renovering av sommarstugan", "build", ColorToken.BROWN, 120, archived = false)
val defaults = listOf(groceries, restaurants, housing, home, utilities, insurance, transport, travel, entertainment, health, children, shopping, fees, other)
private val shares5050 = mapOf(jacob.id to 50, anna.id to 50)
// Default shares are basis points (share_bp, SPEC §14.2.4, AC-6.1).
private val shares5050 = mapOf(jacob.id to 5_000, anna.id to 5_000)
val vardag = Account(AccountId("01920000-0000-7000-8000-0000000000b1"), "Vardag", shares5050, "2026-01-04T09:00:00.000Z", archived = false)
val huset = Account(AccountId("01920000-0000-7000-8000-0000000000b2"), "Huset", mapOf(jacob.id to 60, anna.id to 40), "2026-01-05T09:00:00.000Z", archived = false)
val huset = Account(AccountId("01920000-0000-7000-8000-0000000000b2"), "Huset", mapOf(jacob.id to 6_000, anna.id to 4_000), "2026-01-05T09:00:00.000Z", archived = false)
val japan = Account(AccountId("01920000-0000-7000-8000-0000000000b3"), "Japan 2027", shares5050, "2026-03-01T09:00:00.000Z", archived = false)
val gotland = Account(AccountId("01920000-0000-7000-8000-0000000000b4"), "Gotland 2026", shares5050, "2026-05-20T09:00:00.000Z", archived = true)
val sommarstugan = Account(AccountId("01920000-0000-7000-8000-0000000000b5"), "Sommarstugan i Österåker 2027", mapOf(jacob.id to 60, anna.id to 40), "2026-10-17T09:00:00.000Z", archived = false)
val sommarstugan = Account(AccountId("01920000-0000-7000-8000-0000000000b5"), "Sommarstugan i Österåker 2027", mapOf(jacob.id to 6_000, anna.id to 4_000), "2026-10-17T09:00:00.000Z", archived = false)
val accounts = listOf(vardag, huset, japan, gotland)
val vardagSummary = AccountSummary(vardag, 118_450, 11)
@@ -307,6 +307,8 @@ data class FilterToggle(val groupLabel: String, val options: List<String>, val s
/**
* The analytics filters (design-spec §3.10): period chip ([period] "Senaste 6 mån", TalkBack [periodSpoken]) with
* [granularity] "Månad | Vecka"; then [measure] and [groupBy] at half width each. At ≥ 1.5× one control per row.
* [periodOwnRow] puts the period chip on a row of its own and the granularity toggle on the next, full width, so a long
* custom range label ("Anpassad · 24 aug.–17 okt.") is not truncated (design frame S-11-analytics-week).
*/
@Composable
fun AnalyticsFilterBar(
@@ -317,6 +319,7 @@ fun AnalyticsFilterBar(
measure: FilterToggle,
groupBy: FilterToggle,
modifier: Modifier = Modifier,
periodOwnRow: Boolean = false,
) {
val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE
val chip: @Composable (Modifier) -> Unit = { m ->
@@ -343,6 +346,13 @@ fun AnalyticsFilterBar(
toggle(granularity, Modifier.fillMaxWidth())
toggle(measure, Modifier.fillMaxWidth())
toggle(groupBy, Modifier.fillMaxWidth())
} else if (periodOwnRow) {
chip(Modifier)
toggle(granularity, Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
toggle(measure, Modifier.weight(1f))
toggle(groupBy, Modifier.weight(1f))
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) {
chip(Modifier.weight(1f))
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -41,10 +42,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -85,9 +88,22 @@ fun KvittCard(
/** The fill of a segmented group's items: `card` (default) or `surface-container-low` (soft). */
enum class GroupFill { Card, Soft }
/** One item of a [SegmentedGroup]: its shape and fill. */
/** One item of a [SegmentedGroup]: its shape and fill. Built by [SegmentedGroup], or by [segmentedItem] row by row. */
class SegmentedItemScope internal constructor(val index: Int, val count: Int, val shape: Shape, val fill: Color)
/**
* The scope of item [index] of [count] of a segmented group, for building the group one row at a time where
* [SegmentedGroup] cannot hold the rows, such as one `LazyColumn` item per row (S-12): the item gets the §3.5 group
* shape (outer corners 24 on the ends, inner 8) and the [fill]; the caller spaces the items with
* `KvittTheme.spacing.groupGap`. Unlike [SegmentedGroup] it casts no shared group shadow (one per item would show
* between the rows), so lazily built groups are flat in light theme too.
*/
@Composable
fun segmentedItem(index: Int, count: Int, fill: GroupFill = GroupFill.Card): SegmentedItemScope {
val color = if (fill == GroupFill.Card) KvittTheme.colors.card else MaterialTheme.colorScheme.surfaceContainerLow
return SegmentedItemScope(index, count, KvittTheme.shapes.groupItem(index, count), color)
}
/**
* A vertical group of items 3 dp apart with outer corners 24 and inner corners 8 (design-spec §3.5). Each item draws
* its own background with `scope.shape` and `scope.fill` (so a row can tint itself, as the settlement row does).
@@ -110,7 +126,10 @@ enum class ButtonKind { Filled, Tonal, Tertiary, Outlined, Text, DangerText, Err
/** Button sizes: small 40 dp, medium 56 dp. */
enum class ButtonSize { Small, Medium }
/** A Kvitt button: radius full, label-large weight 600, optional leading icon (design-spec §3.11). */
/**
* A Kvitt button: radius full, label-large weight 600, optional leading icon (design-spec §3.11). [loading] shows the
* in-button progress form (a 20 dp indicator in the content colour instead of the icon, the fill kept, not clickable).
*/
@Composable
fun KvittButton(
text: String,
@@ -122,6 +141,7 @@ fun KvittButton(
iconFilled: Boolean = false,
enabled: Boolean = true,
contentColor: Color? = null,
loading: Boolean = false,
) {
val scheme = MaterialTheme.colorScheme
val (container, content) = when (kind) {
@@ -145,22 +165,28 @@ fun KvittButton(
)
val padding = PaddingValues(horizontal = if (kind == ButtonKind.Text || kind == ButtonKind.DangerText) KvittTheme.spacing.s3 else horizontal)
val label: @Composable RowScope.() -> Unit = {
if (icon != null) {
if (loading) {
CircularProgressIndicator(Modifier.size(KvittDimens.buttonProgress), color = content, strokeWidth = KvittDimens.buttonProgressStroke)
Spacer(Modifier.width(KvittTheme.spacing.s2))
} else if (icon != null) {
KvittIconImage(icon, contentDescription = null, filled = iconFilled, size = iconSize)
Spacer(Modifier.width(KvittTheme.spacing.s2))
}
Text(text, style = textStyle, maxLines = 2, textAlign = TextAlign.Center)
}
val sized = modifier.heightIn(min = height)
// Loading (design-spec §3.11, S-1 "Parar ihop…"): the enabled look with a 20 dp progress indicator in the content
// colour in place of the icon; taps do nothing and TalkBack reads the button as disabled.
val sized = modifier.heightIn(min = height).then(if (loading) Modifier.semantics { disabled() } else Modifier)
val click = if (loading) ({}) else onClick
if (kind == ButtonKind.Outlined) {
OutlinedButton(
onClick, sized, enabled = enabled, shape = CircleShape, colors = colors, contentPadding = padding,
click, sized, enabled = enabled, shape = CircleShape, colors = colors, contentPadding = padding,
border = BorderStroke(KvittDimens.divider, scheme.outlineVariant), content = label,
)
} else if (kind == ButtonKind.Text || kind == ButtonKind.DangerText) {
TextButton(onClick, sized, enabled = enabled, shape = CircleShape, colors = colors, contentPadding = padding, content = label)
TextButton(click, sized, enabled = enabled, shape = CircleShape, colors = colors, contentPadding = padding, content = label)
} else {
Button(onClick, sized, enabled = enabled, shape = CircleShape, colors = colors, contentPadding = padding, content = label)
Button(click, sized, enabled = enabled, shape = CircleShape, colors = colors, contentPadding = padding, content = label)
}
}
@@ -311,8 +337,11 @@ fun SettingsRow(
}
/**
* Empty state: flat section, centred, decorative illustration (three rotated category tiles), title, body and an
* optional medium button (design-spec §3.11).
* Empty state: flat section, centred, decorative illustration, title, body and an optional medium button
* (design-spec §3.11). The illustration is either the pile of three rotated category tiles ([illustration]) or, with
* [icon], one illustration tile (Cookie9, fallback circle; `primary-container` with the icon in
* `on-primary-container`), as in the S-8 / S-10 missing states (`sync_problem`). An online-only action is drawn
* disabled with [actionEnabled] false and the "Kräver anslutning" hint ([actionDisabledHint]) under it (SPEC §9.9).
*/
@Composable
fun EmptyState(
@@ -321,23 +350,38 @@ fun EmptyState(
modifier: Modifier = Modifier,
illustration: List<Category> = emptyList(),
illustrationContainer: Boolean = false,
icon: KvittIcon? = null,
action: String? = null,
actionIcon: KvittIcon? = null,
actionKind: ButtonKind = ButtonKind.Filled,
actionEnabled: Boolean = true,
actionDisabledHint: String? = stringResource(R.string.common_needs_connection),
onAction: () -> Unit = {},
) {
val scheme = MaterialTheme.colorScheme
Column(
modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow, KvittTheme.shapes.card)
.background(scheme.surfaceContainerLow, KvittTheme.shapes.card)
.padding(horizontal = KvittDimens.emptyHorizontal, vertical = KvittDimens.emptyVertical),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittDimens.emptyGap),
) {
if (illustration.isNotEmpty()) TilePile(illustration, illustrationContainer)
if (icon != null) {
Box(Modifier.size(KvittDimens.emptyIconTile).background(scheme.primaryContainer, KvittTheme.shapes.illustration).clearAndSetSemantics { }, contentAlignment = Alignment.Center) {
KvittIconImage(icon, null, filled = true, size = KvittDimens.emptyIconTile * KvittDimens.TILE_ICON_RATIO, tint = scheme.onPrimaryContainer)
}
} else if (illustration.isNotEmpty()) {
TilePile(illustration, illustrationContainer)
}
Text(title, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
Text(body, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
if (action != null) KvittButton(action, onAction, kind = actionKind, size = ButtonSize.Medium, icon = actionIcon)
Text(body, style = MaterialTheme.typography.bodyLarge, color = scheme.onSurfaceVariant, textAlign = TextAlign.Center)
if (action != null) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
KvittButton(action, onAction, kind = actionKind, size = ButtonSize.Medium, icon = actionIcon, enabled = actionEnabled)
if (!actionEnabled && actionDisabledHint != null) NeedsConnectionHint(text = actionDisabledHint)
}
}
}
}
@@ -363,13 +407,15 @@ private fun TilePile(categories: List<Category>, container: Boolean) {
/**
* A dialog: `surface-container-high`, radius 28, padding 24, gap 14; centred icon tile (destructive: error
* container), centred title, muted body, right-aligned dismiss and confirm (design-spec §3.11).
* container), centred title, muted body, right-aligned dismiss and confirm (design-spec §3.11). [body] null hides the
* body line (a discard question needs none). [styledBody], when given, replaces [body] with styled text, for a body
* with bold parts from the resources (`update_permission_body`, S-17; see [styledStringResource]).
*/
@Composable
fun ConfirmDialog(
icon: KvittIcon,
title: String,
body: String,
body: String?,
confirm: String,
dismiss: String,
onConfirm: () -> Unit,
@@ -377,9 +423,12 @@ fun ConfirmDialog(
destructive: Boolean = false,
tertiaryTile: Boolean = false,
confirmIcon: KvittIcon? = null,
styledBody: AnnotatedString? = null,
extra: (@Composable ColumnScope.() -> Unit)? = null,
) {
Dialog(onDismissRequest = onDismiss) { ConfirmDialogContent(icon, title, body, confirm, dismiss, onConfirm, onDismiss, destructive, tertiaryTile, confirmIcon, extra) }
Dialog(onDismissRequest = onDismiss) {
ConfirmDialogContent(icon, title, body, confirm, dismiss, onConfirm, onDismiss, destructive, tertiaryTile, confirmIcon, styledBody, extra)
}
}
/** The content of [ConfirmDialog], also used to screenshot the dialog without a window. */
@@ -387,7 +436,7 @@ fun ConfirmDialog(
fun ConfirmDialogContent(
icon: KvittIcon,
title: String,
body: String,
body: String?,
confirm: String,
dismiss: String,
onConfirm: () -> Unit,
@@ -395,6 +444,7 @@ fun ConfirmDialogContent(
destructive: Boolean = false,
tertiaryTile: Boolean = false,
confirmIcon: KvittIcon? = null,
styledBody: AnnotatedString? = null,
extra: (@Composable ColumnScope.() -> Unit)? = null,
) {
val scheme = MaterialTheme.colorScheme
@@ -403,13 +453,14 @@ fun ConfirmDialogContent(
tertiaryTile -> scheme.tertiaryContainer to scheme.onTertiaryContainer
else -> scheme.secondaryContainer to scheme.onSecondaryContainer
}
val text = styledBody ?: body?.let(::AnnotatedString)
Surface(shape = KvittTheme.shapes.card, color = scheme.surfaceContainerHigh, shadowElevation = KvittElevation.overlay) {
Column(Modifier.padding(KvittDimens.dialogPadding), verticalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap)) {
Box(Modifier.align(Alignment.CenterHorizontally).size(KvittDimens.dialogIconTile).background(tile, KvittTheme.shapes.avatarLarge), contentAlignment = Alignment.Center) {
KvittIconImage(icon, null, filled = true, tint = onTile)
}
Text(title, Modifier.fillMaxWidth(), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
Text(body, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant)
if (text != null) Text(text, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant)
extra?.invoke(this)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.End)) {
KvittButton(dismiss, onDismiss, kind = ButtonKind.Text)
@@ -25,6 +25,10 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -201,20 +205,19 @@ fun DescriptionField(value: String, onValueChange: (String) -> Unit, error: Stri
}
/**
* The five tiles of the quick row (design-spec §3.7, UX-3): [frequent] (the account's most used active categories,
* `ExpenseEditorOptions.frequentCategoryIds`, D-059) filled up from [active] in `sort_order`; when [selected] is not
* among them it takes the fifth place. Without usage data pass an empty [frequent]: the row is then the selected
* category followed by the first active categories (the fallback of §3.7).
* The five tiles of the quick row (design-spec §3.7, UX-3, D-060): [frequent] is the raw
* `ExpenseEditorOptions.frequentCategoryIds[accountId]` (the account's used active categories by use count, D-059;
* possibly empty), filled up to five from the active categories of [categories] in `sort_order`. When [selected] is
* not among the five it takes the fifth place. Archived categories are never filled in; an archived [selected] (an
* old expense being edited) still takes the fifth place. Callers pass the list as the repository gives it.
*/
fun quickRowCategories(active: List<Category>, frequent: List<CategoryId>, selected: CategoryId?): List<Category> {
val byId = active.associateBy { it.id }
val sorted = active.filter { !it.archived }.sortedBy { it.sortOrder }
val chosen = if (frequent.isEmpty()) {
listOfNotNull(selected?.let { byId[it] }) + sorted
} else {
frequent.mapNotNull { byId[it] } + sorted
}.distinctBy { it.id }.take(KvittDimens.QUICK_ROW_TILES)
val pick = selected?.let { byId[it] } ?: return chosen
fun quickRowCategories(categories: List<Category>, frequent: List<CategoryId>, selected: CategoryId?): List<Category> {
val active = categories.filter { !it.archived }
val activeById = active.associateBy { it.id }
val chosen = (frequent.mapNotNull { activeById[it] } + active.sortedBy { it.sortOrder })
.distinctBy { it.id }
.take(KvittDimens.QUICK_ROW_TILES)
val pick = selected?.let { id -> categories.firstOrNull { it.id == id } } ?: return chosen
return if (chosen.any { it.id == pick.id }) chosen else chosen.take(KvittDimens.QUICK_ROW_TILES - 1) + pick
}
@@ -281,16 +284,40 @@ fun CategoryItem(category: Category, selected: Boolean, onClick: () -> Unit, mod
}
}
}
Text(
name,
style = KvittTheme.typography.tileLabel.let { if (selected) it.copy(fontWeight = FontWeight.Bold) else it },
color = if (selected) scheme.onSurface else scheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 2,
)
TileLabel(name, selected)
}
}
/**
* The label of a quick-row or grid item: up to two centred lines (D-081). The default names fit (a long single word such
* as "Entertainment" still hyphenates); a longer custom name ends its second line in "…" instead of a cut "som-". The
* ellipsis is made here, not by the platform: Android shifts a centred ellipsized line by its unellipsized width, which
* cut its start.
*/
@Composable
private fun TileLabel(name: String, selected: Boolean) {
val scheme = MaterialTheme.colorScheme
var keep by remember(name) { mutableIntStateOf(name.length) }
val shown = if (keep >= name.length) name else name.take(keep).trimEnd() + ELLIPSIS
Text(
shown,
// TalkBack reads the full name when the label is cut.
Modifier.fillMaxWidth().then(if (shown != name) Modifier.clearAndSetSemantics { contentDescription = name } else Modifier),
style = KvittTheme.typography.tileLabel.let { if (selected) it.copy(fontWeight = FontWeight.Bold) else it },
color = if (selected) scheme.onSurface else scheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = KvittDimens.TILE_LABEL_LINES,
onTextLayout = { layout ->
if (layout.hasVisualOverflow && keep > 1) {
// First overflow: cut at the end of the last visible line; then one character at a time until "…" fits.
keep = if (keep >= name.length) (layout.getLineEnd(KvittDimens.TILE_LABEL_LINES - 1) - 1).coerceIn(1, name.length - 1) else keep - 1
}
},
)
}
private const val ELLIPSIS = "\u2026"
@Composable
private fun AllItem(onAll: () -> Unit, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
@@ -1,5 +1,8 @@
package se.hagstedt.kvitt.design.component
import android.graphics.Typeface
import android.text.Spanned
import android.text.style.StyleSpan
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
@@ -12,8 +15,14 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -105,6 +114,31 @@ fun listDate(isoDate: String): String = DateFormatter.listDate(DateFormatter.par
@ReadOnlyComposable
fun detailDate(isoDate: String): String = DateFormatter.detailDate(isoDate, kvittLanguage())
/**
* A string resource with its `<b>` and `<i>` parts kept as styles (stringResource drops them), for text that bolds a
* few words, such as the two Android setting names in `update_permission_body` (S-17).
*/
@Composable
@ReadOnlyComposable
fun styledStringResource(@StringRes id: Int): AnnotatedString = LocalResources.current.getText(id).toAnnotatedString()
private fun CharSequence.toAnnotatedString(): AnnotatedString {
if (this !is Spanned) return AnnotatedString(toString())
val spanned = this
return buildAnnotatedString {
append(spanned.toString())
spanned.getSpans(0, spanned.length, StyleSpan::class.java).forEach { span ->
val style = when (span.style) {
Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
Typeface.BOLD_ITALIC -> SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)
else -> null
}
if (style != null) addStyle(style, spanned.getSpanStart(span), spanned.getSpanEnd(span))
}
}
}
/** The display name of a person as the viewer reads it: "Du" for the viewer, else the display name. */
@Composable
@ReadOnlyComposable
@@ -32,6 +32,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.CornerSize
@@ -67,6 +68,7 @@ fun KvittTextField(
textStyle: TextStyle = MaterialTheme.typography.bodyLarge,
suffix: String? = null,
trailing: (@Composable () -> Unit)? = null,
visualTransformation: VisualTransformation = VisualTransformation.None,
) {
val interaction = remember { MutableInteractionSource() }
val focused by interaction.collectIsFocusedAsState()
@@ -97,6 +99,7 @@ fun KvittTextField(
maxLines = maxLines,
keyboardOptions = KeyboardOptions(capitalization = capitalization, keyboardType = keyboardType, imeAction = imeAction),
keyboardActions = KeyboardActions(onAny = { onIme() }),
visualTransformation = visualTransformation,
interactionSource = interaction,
shape = shape,
colors = TextFieldDefaults.colors(
@@ -1,5 +1,7 @@
package se.hagstedt.kvitt.design.component
import android.content.res.Resources
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -25,19 +27,29 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.ShortNavigationBar
import androidx.compose.material3.ShortNavigationBarItem
import androidx.compose.material3.ShortNavigationBarItemDefaults
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
@@ -182,9 +194,42 @@ fun AddExpenseFab(onClick: () -> Unit, modifier: Modifier = Modifier, expanded:
}
}
/**
* A snackbar whose words are string resources: [text] with [args], and an optional [action] label. [KvittScaffold]
* resolves them while the snackbar is on screen, so its text follows the current configuration (an app-language change
* while it shows) instead of keeping the language it was shown in. [message] and [actionLabel] hold the words as
* resolved when shown, for any other host. Show it with [showKvittSnackbar]. Durations per design-spec §3.11: 4 s
* (`Short`), 10 s with an action (`Long`).
*/
class KvittSnackbarVisuals(
@param:StringRes val text: Int,
val args: List<Any>,
@param:StringRes val action: Int?,
override val message: String,
override val actionLabel: String?,
) : SnackbarVisuals {
override val withDismissAction: Boolean get() = false
override val duration: SnackbarDuration get() = if (action != null) SnackbarDuration.Long else SnackbarDuration.Short
}
/**
* Shows a resource-backed snackbar ([KvittSnackbarVisuals]) in a [KvittScaffold]: [text] formatted with [args], an
* optional [action] label ("Försök igen", "Ångra"). [resources] (for example `LocalResources.current`) resolves the
* fallback words; the scaffold re-resolves them on every composition.
*/
suspend fun SnackbarHostState.showKvittSnackbar(
resources: Resources,
@StringRes text: Int,
args: List<Any> = emptyList(),
@StringRes action: Int? = null,
): SnackbarResult = showSnackbar(
KvittSnackbarVisuals(text, args, action, resources.getString(text, *args.toTypedArray()), action?.let { resources.getString(it) }),
)
/**
* Edge-to-edge scaffold (design-spec §3.11): top bar, content, optional FAB and bottom bar, snackbars above the FAB and
* navigation; the status-bar inset is handled by the app bar.
* navigation; the status-bar inset is handled by the app bar. A snackbar shown with [showKvittSnackbar] is resolved
* from its resources while it shows, so it follows the current language; one shown with a plain `String` keeps it.
*/
@Composable
fun KvittScaffold(
@@ -201,7 +246,14 @@ fun KvittScaffold(
bottomBar = bottomBar,
floatingActionButton = floatingActionButton,
snackbarHost = {
if (snackbarHostState != null) SnackbarHost(snackbarHostState) { data -> KvittSnackbar(data.visuals.message, Modifier.padding(KvittTheme.spacing.s4), data.visuals.actionLabel, { data.performAction() }) }
if (snackbarHostState != null) {
SnackbarHost(snackbarHostState) { data ->
val visuals = data.visuals
val message = if (visuals is KvittSnackbarVisuals) stringResource(visuals.text, *visuals.args.toTypedArray()) else visuals.message
val action = if (visuals is KvittSnackbarVisuals) visuals.action?.let { stringResource(it) } else visuals.actionLabel
KvittSnackbar(message, Modifier.padding(KvittTheme.spacing.s4), action, { data.performAction() })
}
}
},
containerColor = MaterialTheme.colorScheme.surface,
content = content,
@@ -248,20 +300,41 @@ fun InviteCodeDisplay(code: String, modifier: Modifier = Modifier) {
/**
* The invite code field (design-spec §3.11, S-1): filled field "Inbjudningskod", value title-large, uppercase,
* normalised and grouped while typing; a trailing [pasteLabel] text button ("Klistra in", a feature string).
* The value wraps after a group when it does not fit on one line (200 % font scale: "7K3M-QX9D-" over "2HPA"), never
* cut. [placeholder] is shown while empty ("XXXX-XXXX-XXXX"). [imeAction] `Next` moves the focus to the next field
* unless [onIme] is given; `Done` runs [onIme].
*/
@Composable
fun InviteCodeField(value: String, onValueChange: (String) -> Unit, pasteLabel: String, onPaste: () -> Unit, modifier: Modifier = Modifier, error: String? = null, enabled: Boolean = true) {
fun InviteCodeField(
value: String,
onValueChange: (String) -> Unit,
pasteLabel: String,
onPaste: () -> Unit,
modifier: Modifier = Modifier,
error: String? = null,
enabled: Boolean = true,
placeholder: String? = null,
imeAction: ImeAction = ImeAction.Done,
onIme: (() -> Unit)? = null,
) {
val focus = LocalFocusManager.current
KvittTextField(
value = groupInviteCode(normalizeInviteCode(value)),
onValueChange = { onValueChange(normalizeInviteCode(it)) },
label = stringResource(R.string.common_invite_code),
modifier = modifier,
error = error,
placeholder = placeholder,
singleLine = false,
maxLines = INVITE_MAX_LINES,
capitalization = KeyboardCapitalization.Characters,
keyboardType = KeyboardType.Ascii,
imeAction = imeAction,
onIme = onIme ?: { if (imeAction == ImeAction.Next) focus.moveFocus(FocusDirection.Next) },
textStyle = MaterialTheme.typography.titleLarge,
enabled = enabled,
readOnly = !enabled,
visualTransformation = GroupBreaks,
trailing = if (enabled) {
{ KvittButton(pasteLabel, onPaste, kind = ButtonKind.Text, icon = KvittIcon.ContentPaste) }
} else {
@@ -269,3 +342,26 @@ fun InviteCodeField(value: String, onValueChange: (String) -> Unit, pasteLabel:
},
)
}
/** Three groups of four wrap onto at most three lines, one group per line at the largest font scale. */
private const val INVITE_MAX_LINES = 3
/**
* Shows a zero-width space after every hyphen of the grouped code, so a line may break after any group: the line
* breaker does not break between a hyphen and a digit ("QX9D-2HPA"), which cut the value at 200 % font scale. The value
* itself is unchanged.
*/
private object GroupBreaks : VisualTransformation {
private const val BREAK = '\u200B'
override fun filter(text: AnnotatedString): TransformedText {
val original = text.text
val shown = buildString { original.forEach { c -> append(c); if (c == '-') append(BREAK) } }
val mapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int = offset + original.take(offset).count { it == '-' }
override fun transformedToOriginal(offset: Int): Int = offset - shown.take(offset).count { it == BREAK }
}
return TransformedText(AnnotatedString(shown), mapping)
}
}
@@ -445,7 +445,7 @@ fun FullSplitPicker(me: User, partner: User, bearer: UserId, onBearer: (UserId)
groupLabel = stringResource(R.string.editor_split_full_question),
modifier = Modifier.fillMaxWidth(),
minHeight = KvittDimens.fullPickerMinHeight,
spoken = people.map { stringResource(R.string.editor_split_full_on, personNameInSentence(it)) },
spoken = people.map { fullOnLabel(it) },
leading = { PersonAvatar(people[it], KvittDimens.avatarFullPicker) },
)
SplitBar(if (bearer == me.id) 100f else 0f)
@@ -453,6 +453,25 @@ fun FullSplitPicker(me: User, partner: User, bearer: UserId, onBearer: (UserId)
}
}
/** The ratio a feature shows in the default label, from the account's default shares. */
fun viewerPercentOf(defaultShares: Map<UserId, Int>, viewer: UserId): Int = defaultShares[viewer] ?: 50
/**
* "Allt på dig" for the viewer (`editor_split_full_on_you`, the object form Swedish needs), else "Allt på Anna"
* (`editor_split_full_on`). Used by the full-split picker, the split chip (S-7, S-19) and the split fact (S-8).
*/
@Composable
fun fullOnLabel(bearer: User): String =
if (LocalKvittHousehold.current.isViewer(bearer.id)) {
stringResource(R.string.editor_split_full_on_you)
} else {
stringResource(R.string.editor_split_full_on, bearer.displayName)
}
/**
* The viewer's whole percent of an account's default split, for the default label ("Kontots standard 60/40"):
* [defaultShares] holds basis points (`share_bp`, SPEC §14.2.4, AC-6.1), so 6000 gives 60; a missing entry gives 50.
* Fractions of a percent are cut (6050 gives 60), as the label shows whole percents.
*/
fun viewerPercentOf(defaultShares: Map<UserId, Int>, viewer: UserId): Int = (defaultShares[viewer] ?: HALF_BP) / BP_PER_PERCENT
private const val HALF_BP = 5_000
private const val BP_PER_PERCENT = 100
@@ -62,14 +62,23 @@ data class AmountInput(val raw: String = "") {
/** Percent fields of the ratio editor: at most 100 (§3.7). */
const val MAX_PERCENT_HUNDREDTHS = 10_000L
/** Restores the text of a stored amount for editing: 124 760 -> "1247.6", 41 200 -> "412". */
fun of(ore: Long): AmountInput {
val whole = ore / 100
val cents = ore % 100
/**
* Restores the text of a stored amount for editing, always with both decimals, as money is shown (SPEC §9.8):
* 124 760 -> "1247.60" (shown "1 247,60"), 1 240 000 -> "12400.00", 41 200 -> "412.00", 5 -> "0.05".
*/
fun of(ore: Long): AmountInput = AmountInput("${ore / 100}.${(ore % 100).toString().padStart(2, '0')}")
/**
* Restores a percent field from basis points without trailing zeros, as the ratio fields are typed:
* 6000 -> "60", 6050 -> "60.5", 3333 -> "33.33".
*/
fun percent(bp: Long): AmountInput {
val whole = bp / 100
val hundredths = bp % 100
val raw = when {
cents == 0L -> whole.toString()
cents % 10 == 0L -> "$whole.${cents / 10}"
else -> "$whole.${cents.toString().padStart(2, '0')}"
hundredths == 0L -> whole.toString()
hundredths % 10 == 0L -> "$whole.${hundredths / 10}"
else -> "$whole.${hundredths.toString().padStart(2, '0')}"
}
return AmountInput(raw)
}
@@ -138,6 +138,20 @@ object DateFormatter {
"${listDate(start, today, language)}$EN_DASH${listDate(end, today, language)}"
}
/**
* A day range written out, always with the year, for captions (the S-11 week caption): `24 augusti–17 oktober 2026`,
* `1–30 november 2026`, across years `28 december 2025–3 januari 2026` / `24 August–17 October 2026`. Full month
* names, as in running text (D-065 keeps the short forms for lists and axes).
*/
fun dayRangeLong(start: LocalDate, end: LocalDate, language: KvittLanguage): String {
val endText = "${end.day} ${monthName(end.month.index + 1, language)} ${end.year}"
return when {
start.year == end.year && start.month == end.month -> "${start.day}$EN_DASH$endText"
start.year == end.year -> "${start.day} ${monthName(start.month.index + 1, language)}$EN_DASH$endText"
else -> "${start.day} ${monthName(start.month.index + 1, language)} ${start.year}$EN_DASH$endText"
}
}
/**
* Bucket key of the analytics series (`YYYY-MM` or `YYYY-Www`, §14.5.4) as an x label: the short month (`okt`) or
* the week number (`41`).
@@ -164,9 +178,13 @@ object DateFormatter {
private fun monthShort(date: LocalDate, language: KvittLanguage): String =
if (language == KvittLanguage.SWEDISH) SV_MONTHS_SHORT[date.month.index] else EN_MONTHS_SHORT[date.month.index]
private fun monthName(month: Int, language: KvittLanguage): String =
/** The month (1–12) in running text: `oktober` / `October` ("11 utgifter i oktober", S-4). */
fun monthName(month: Int, language: KvittLanguage): String =
if (language == KvittLanguage.SWEDISH) SV_MONTHS[month - 1] else EN_MONTHS[month - 1]
/** The month of a `YYYY-MM` period in running text: `oktober` / `October`. */
fun monthName(period: String, language: KvittLanguage): String = monthName(parsePeriod(period).second, language)
private fun parsePeriod(period: String): Pair<Int, Int> {
val year = period.substringBefore('-').toInt()
val month = period.substringAfter('-').take(2).toInt()
@@ -115,6 +115,8 @@ object KvittDimens {
const val QUICK_ROW_COLUMNS = 6
const val QUICK_ROW_COLUMNS_LARGE = 3
const val QUICK_ROW_TILES = 5
/** Category item labels: at most two lines, the second ends in "…" when a custom name is longer (D-081). */
const val TILE_LABEL_LINES = 2
const val GRID_SHEET_COLUMNS = 4
const val GRID_SHEET_COLUMNS_LARGE = 3
const val LARGE_FONT_SCALE = 1.5f
@@ -198,6 +200,9 @@ object KvittDimens {
val buttonSmallHorizontal = 20.dp
val buttonMediumHorizontal = 26.dp
val buttonMediumIcon = 24.dp
/** In-button progress of a loading button (S-1 "Parar ihop…"): 20 dp, stroke 2.5. */
val buttonProgress = 20.dp
val buttonProgressStroke = 2.5.dp
val badgeVertical = 2.dp
val badgeHorizontal = 10.dp
val sectionHeaderStart = 4.dp
@@ -207,6 +212,8 @@ object KvittDimens {
val emptyGap = 10.dp
val illustrationWidth = 170.dp
val illustrationHeight = 110.dp
/** The single illustration tile of an empty state with an icon (S-8 / S-10 missing): Cookie9 72, icon at 52 %. */
val emptyIconTile = 72.dp
val dialogIconTile = 56.dp
val dialogPadding = 24.dp
val dialogGap = 14.dp
@@ -222,4 +229,43 @@ object KvittDimens {
const val DISABLED_CONTENT_ALPHA = 0.38f
const val DISABLED_CONTAINER_ALPHA = 0.12f
const val DELETED_ALPHA = 0.62f
// Screen measures of design-spec §4 that the feature modules use (moved here from the features, M6b).
/** App mark (Sunny, `primary`, `receipt_long`): S-1 brand block 88 with icon 44; S-22 About 96 with icon 48. */
val appMark = 88.dp
val appMarkIcon = 44.dp
val aboutAppMark = 96.dp
val aboutAppMarkIcon = 48.dp
/** S-1 brand block padding 28 top / 12 bottom; onboarding column gap 14 (S-1, S-2, S-18). */
val brandTop = 28.dp
val brandBottom = 12.dp
val onboardingGap = 14.dp
/** Pinned bottom action bar of S-1, S-2 and S-18: padding 12 vertical / 16 horizontal. */
val bottomBarVertical = 12.dp
val bottomBarHorizontal = 16.dp
/** S-1 help line icon 20. */
val helpIcon = 20.dp
/** S-2 intro padding 24 top / 4 sides / 4 bottom; profile card padding 22 vertical / 20 horizontal. */
val profileIntroTop = 24.dp
val profileIntroSide = 4.dp
val profileIntroBottom = 4.dp
val profileCardVertical = 22.dp
val profileCardHorizontal = 20.dp
/** S-2 avatar badge: a 40 dp `primary` circle with `add_a_photo` 20 and a 3 dp `card` ring. */
val avatarBadge = 40.dp
val avatarBadgeIcon = 20.dp
val avatarBadgeRing = 3.dp
/** S-18 device removed: padding top 80, Cookie9 tile 112 with `phonelink_erase` 52. */
val removedTop = 80.dp
val removedTile = 112.dp
val removedIcon = 52.dp
/** S-4 empty: the welcome card's two avatars, 44 dp, overlapping by 10. */
val welcomeAvatar = 44.dp
val welcomeAvatarOverlap = 10.dp
/** S-4 loading: the sync pill skeleton is 200 dp wide (40 high, [pillMinHeight]). */
val skeletonPillWidth = 200.dp
/** S-10 hero: padding 22 vertical / 20 horizontal, `arrow_forward` 28 between the avatars. */
val settlementHeroVertical = 22.dp
val settlementHeroHorizontal = 20.dp
val settlementArrow = 28.dp
}
@@ -16,6 +16,8 @@
<!-- required on a name field -->
<string name="common_name_required">Skriv ett namn</string>
<string name="common_retry">Försök igen</string>
<string name="common_ok">OK</string>
<string name="common_undo">Ångra</string>
<!-- TalkBack label of the overflow icon -->
<string name="common_more_options">Fler alternativ</string>
<!-- the viewer as a name, sentence start -->
@@ -160,6 +162,7 @@
<string name="editor_split_mode_exact">Exakta belopp</string>
<string name="editor_split_mode_full">Allt på en person</string>
<string name="editor_split_full_on">Allt på %1$s</string>
<string name="editor_split_full_on_you">Allt på dig</string>
<!-- full mode prompt -->
<string name="editor_split_full_question">Vem står för hela beloppet?</string>
<!-- trailing text of the other person's read-only field in exact mode -->
@@ -16,6 +16,8 @@
<!-- required on a name field -->
<string name="common_name_required">Enter a name</string>
<string name="common_retry">Try again</string>
<string name="common_ok">OK</string>
<string name="common_undo">Undo</string>
<!-- TalkBack label of the overflow icon -->
<string name="common_more_options">More options</string>
<!-- the viewer as a name, sentence start -->
@@ -162,6 +164,7 @@
<string name="editor_split_mode_exact">Exact amounts</string>
<string name="editor_split_mode_full">All on one person</string>
<string name="editor_split_full_on">All on %1$s</string>
<string name="editor_split_full_on_you">All on you</string>
<!-- full mode prompt -->
<string name="editor_split_full_question">Who pays the whole amount?</string>
<!-- trailing text of the other person's read-only field in exact mode -->
@@ -45,12 +45,21 @@ class ComponentLogicTest {
Category(CategoryId("c$n"), DefaultCategoryKey.entries[n], null, "category", ColorToken.GREY, sort, archived)
@Test
fun quickRowFallsBackToSelectedThenSortOrder() {
fun quickRowWithoutUsageFillsFromSortOrderAndPutsTheSelectedFifth() {
// D-060: an empty frequent list is filled from sort_order like any short one; the selected one takes place five.
val active = (0 until 8).map { cat(it, it * 10) }
assertEquals(listOf("c5", "c0", "c1", "c2", "c3"), quickRowCategories(active, emptyList(), CategoryId("c5")).map { it.id.value })
assertEquals(listOf("c0", "c1", "c2", "c3", "c5"), quickRowCategories(active, emptyList(), CategoryId("c5")).map { it.id.value })
assertEquals(listOf("c0", "c1", "c2", "c3", "c4"), quickRowCategories(active, emptyList(), CategoryId("c2")).map { it.id.value })
assertEquals(listOf("c0", "c1", "c2", "c3", "c4"), quickRowCategories(active, emptyList(), null).map { it.id.value })
}
@Test
fun quickRowShortUsageIsFilledUpToFive() {
val active = (0 until 8).map { cat(it, it * 10) }
assertEquals(listOf("c7", "c0", "c1", "c2", "c3"), quickRowCategories(active, listOf(CategoryId("c7")), CategoryId("c1")).map { it.id.value })
assertEquals(listOf("c7", "c0", "c1", "c2", "c6"), quickRowCategories(active, listOf(CategoryId("c7")), CategoryId("c6")).map { it.id.value })
}
@Test
fun quickRowUsesFrequentAndPutsTheSelectedFifth() {
val active = (0 until 8).map { cat(it, it * 10) }
@@ -63,6 +72,17 @@ class ComponentLogicTest {
fun quickRowSkipsArchived() {
val active = listOf(cat(0, 0, archived = true)) + (1 until 8).map { cat(it, it * 10) }
assertEquals(listOf("c1", "c2", "c3", "c4", "c5"), quickRowCategories(active, emptyList(), null).map { it.id.value })
assertEquals(listOf("c1", "c2", "c3", "c4", "c5"), quickRowCategories(active, listOf(CategoryId("c0")), null).map { it.id.value })
// An archived selected category (an old expense being edited) still shows, in the fifth place.
assertEquals(listOf("c1", "c2", "c3", "c4", "c0"), quickRowCategories(active, emptyList(), CategoryId("c0")).map { it.id.value })
}
@Test
fun viewerPercentReadsBasisPoints() {
assertEquals(60, viewerPercentOf(mapOf(jacob to 6_000, anna to 4_000), jacob))
assertEquals(40, viewerPercentOf(mapOf(jacob to 6_000, anna to 4_000), anna))
assertEquals(50, viewerPercentOf(emptyMap(), jacob))
assertEquals(60, viewerPercentOf(mapOf(jacob to 6_050, anna to 3_950), jacob))
}
@Test
@@ -0,0 +1,80 @@
package se.hagstedt.kvitt.design.component
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
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.LocalResources
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.text.font.FontWeight
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 se.hagstedt.kvitt.core.design.R
import se.hagstedt.kvitt.design.KvittTheme
import java.util.Locale
/**
* A snackbar shown with [showKvittSnackbar] follows a language change while it is on screen (M6b item 5), and
* [styledStringResource] keeps the `<b>` parts of a resource.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class ScaffoldSnackbarTest {
@get:Rule
val compose = createComposeRule()
@Test
fun snackbarTextFollowsTheLanguageWhileShown() {
var language by mutableStateOf("sv-SE")
val host = SnackbarHostState()
compose.setContent {
val base = LocalContext.current
val baseConfiguration = LocalConfiguration.current
val context = remember(language) {
base.createConfigurationContext(Configuration(baseConfiguration).apply { setLocales(LocaleList(Locale.forLanguageTag(language))) })
}
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
LocalResources provides context.resources,
) {
val resources = LocalResources.current
LaunchedEffect(Unit) { host.showKvittSnackbar(resources, R.string.common_could_not_reach_server, action = R.string.common_retry) }
KvittTheme { KvittScaffold(snackbarHostState = host) { Text("") } }
}
}
compose.waitForIdle()
compose.onNodeWithText("Kunde inte nå servern").assertExists()
compose.onNodeWithText("Försök igen").assertExists()
language = "en-GB"
compose.waitForIdle()
compose.onNodeWithText("Could not reach the server").assertExists()
compose.onNodeWithText("Try again").assertExists()
}
@Test
fun styledStringsKeepTheirBoldParts() {
var bold = emptyList<String>()
compose.setContent {
val text = styledStringResource(R.string.update_permission_body)
bold = text.spanStyles.filter { it.item.fontWeight == FontWeight.Bold }.map { text.text.substring(it.start, it.end) }
}
compose.waitForIdle()
assertEquals(2, bold.size)
assertTrue(bold.all { it.isNotBlank() })
}
}
@@ -60,8 +60,19 @@ class AmountInputTest {
@Test
fun restoresStoredAmounts() {
assertEquals("1247.6", AmountInput.of(124_760).raw)
assertEquals("412", AmountInput.of(41_200).raw)
assertEquals("1247.60", AmountInput.of(124_760).raw)
assertEquals("1\u00A0247,60", AmountInput.of(124_760).display(KvittLanguage.SWEDISH))
assertEquals("12\u00A0400,00", AmountInput.of(1_240_000).display(KvittLanguage.SWEDISH))
assertEquals("412.00", AmountInput.of(41_200).raw)
assertEquals("0.05", AmountInput.of(5).raw)
assertEquals(124_760L, AmountInput.of(124_760).ore)
}
@Test
fun restoresPercentFields() {
assertEquals("60", AmountInput.percent(6_000).raw)
assertEquals("60.5", AmountInput.percent(6_050).raw)
assertEquals("33.33", AmountInput.percent(3_333).raw)
assertEquals("0", AmountInput.percent(0).raw)
}
}
@@ -86,6 +86,17 @@ class DateFormatterTest {
assertEquals("24 aug.–17 okt.", DateFormatter.dayRange(LocalDate(2026, 8, 24), LocalDate(2026, 10, 17), today, SWEDISH))
}
@Test
fun monthNamesAndLongDayRanges() {
assertEquals("oktober", DateFormatter.monthName(10, SWEDISH))
assertEquals("October", DateFormatter.monthName("2026-10", ENGLISH))
assertEquals("maj", DateFormatter.monthName("2026-05", SWEDISH))
assertEquals("24 augusti–17 oktober 2026", DateFormatter.dayRangeLong(LocalDate(2026, 8, 24), LocalDate(2026, 10, 17), SWEDISH))
assertEquals("24 August–17 October 2026", DateFormatter.dayRangeLong(LocalDate(2026, 8, 24), LocalDate(2026, 10, 17), ENGLISH))
assertEquals("1–30 november 2026", DateFormatter.dayRangeLong(LocalDate(2026, 11, 1), LocalDate(2026, 11, 30), SWEDISH))
assertEquals("28 december 2025–3 januari 2026", DateFormatter.dayRangeLong(LocalDate(2025, 12, 28), LocalDate(2026, 1, 3), SWEDISH))
}
@Test
fun isoWeeks() {
assertEquals(41, DateFormatter.isoWeek(LocalDate(2026, 10, 5)))