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)))
@@ -131,7 +131,7 @@ fun AccountEditorContent(ui: AccountEditorUi, actions: AccountEditorActions, mod
ConfirmDialog(
icon = KvittIcon.Delete,
title = stringResource(DesignR.string.common_discard_title),
body = "",
body = null,
confirm = stringResource(DesignR.string.common_discard),
dismiss = stringResource(DesignR.string.common_keep_editing),
onConfirm = actions.onDiscard,
@@ -58,6 +58,7 @@ import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.GroupKey
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.core.design.R as DesignR
/**
@@ -141,13 +142,15 @@ private fun Filters(state: AnalyticsUiState, events: AnalyticsEvents) {
listOf(stringResource(R.string.analytics_group_by_category), stringResource(R.string.analytics_group_by_account)),
GroupBy.entries.indexOf(filter.groupBy),
) { events.onGroupBy(GroupBy.entries[it]) },
// A custom range label ("Anpassad · 24 aug.–17 okt.") gets a row of its own (design frame S-11-analytics-week).
periodOwnRow = filter.preset == RangePreset.CUSTOM,
)
}
/** The range caption: "maj–okt 2026 · Hushållet", or "24 aug.–17 okt. · 8 veckor" in week granularity. */
@Composable
private fun totalCaption(data: AnalyticsState): String {
val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage())
val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage(), long = true)
val second = when {
data.filter.granularity == Granularity.WEEK -> pluralStringResource(R.plurals.analytics_weeks, data.buckets.size, data.buckets.size)
data.filter.measure == Measure.HOUSEHOLD -> stringResource(R.string.analytics_measure_household)
@@ -192,7 +195,7 @@ private fun Ready(content: AnalyticsContent.Ready, events: AnalyticsEvents, open
private fun OverTime(content: AnalyticsContent.Ready, onBucket: (String) -> Unit) {
val data = content.data
val groups = chartGroups(data, content.categories, content.accounts)
val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage())
val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage(), long = true)
val highest = data.buckets.maxByOrNull { it.totalOre }
val lowest = data.buckets.minByOrNull { it.totalOre }
val summary = if (highest != null && lowest != null) {
@@ -31,14 +31,18 @@ internal fun presetLabel(preset: RangePreset): Int = when (preset) {
internal fun measureLabel(measure: Measure): Int =
if (measure == Measure.HOUSEHOLD) R.string.analytics_measure_household else R.string.analytics_measure_my_share
/** The range in a caption: whole months "maj–okt 2026" for a month preset, else days "24 aug.–17 okt.". */
internal fun rangeText(start: String, end: String, filter: AnalyticsFilter, today: LocalDate, language: KvittLanguage): String {
/**
* The range in a title or caption: whole months "maj–okt 2026" for a month preset, else days: short "24 aug.–17 okt."
* or, with [long] (the total card caption and the chart summary, design frame S-11-analytics-week), written out with the
* year "24 augusti–17 oktober 2026" (`DateFormatter.dayRangeLong`).
*/
internal fun rangeText(start: String, end: String, filter: AnalyticsFilter, today: LocalDate, language: KvittLanguage, long: Boolean = false): String {
val first = LocalDate.parse(start)
val last = LocalDate.parse(end)
return if (filter.granularity == Granularity.MONTH && filter.preset != RangePreset.CUSTOM) {
DateFormatter.monthRange(first, last, language)
} else {
DateFormatter.dayRange(first, last, today, language)
return when {
filter.granularity == Granularity.MONTH && filter.preset != RangePreset.CUSTOM -> DateFormatter.monthRange(first, last, language)
long -> DateFormatter.dayRangeLong(first, last, language)
else -> DateFormatter.dayRange(first, last, today, language)
}
}
@@ -42,12 +42,8 @@
<item quantity="one">%d utgift</item>
<item quantity="other">%d utgifter</item>
</plurals>
<!-- proposed (M7e): radio-group name of the month/week toggle -->
<string name="analytics_granularity">Indelning</string>
<!-- proposed (M7e): radio-group name of the measure toggle -->
<string name="analytics_measure">Mått</string>
<!-- proposed (M7e): radio-group name of the group-by toggle -->
<string name="analytics_group_by">Gruppera efter</string>
<!-- proposed (M7e): confirm button of the custom range picker -->
<string name="analytics_range_apply">Använd</string>
</resources>
@@ -42,12 +42,8 @@
<item quantity="one">%d expense</item>
<item quantity="other">%d expenses</item>
</plurals>
<!-- proposed (M7e): radio-group name of the month/week toggle -->
<string name="analytics_granularity">Interval</string>
<!-- proposed (M7e): radio-group name of the measure toggle -->
<string name="analytics_measure">Measure</string>
<!-- proposed (M7e): radio-group name of the group-by toggle -->
<string name="analytics_group_by">Group by</string>
<!-- proposed (M7e): confirm button of the custom range picker -->
<string name="analytics_range_apply">Apply</string>
</resources>
@@ -43,7 +43,6 @@ import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.MoneyText
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SegmentedGroup
@@ -141,12 +140,13 @@ private fun ColumnScope.Missing(state: ExpenseDetailUiState, actions: ExpenseDet
EmptyState(
title = stringResource(R.string.expense_detail_missing),
body = stringResource(R.string.expense_detail_missing_body),
icon = KvittIcon.SyncProblem,
action = stringResource(R.string.expense_detail_sync_now),
actionIcon = KvittIcon.Sync,
actionKind = ButtonKind.Tonal,
actionEnabled = state.online,
onAction = { if (state.online && !state.syncing) actions.syncNow() },
)
if (!state.online) NeedsConnectionHint(Modifier.align(Alignment.CenterHorizontally))
}
@Composable
@@ -256,7 +256,7 @@ private fun modeLabel(detail: ExpenseDetailState, me: User, partner: User): Stri
SplitMode.EXACT -> splitModeLabel(SplitMode.EXACT, "")
SplitMode.FULL -> {
val bearer = detail.shares.firstOrNull { it.shareOre > 0 }?.userId
if (bearer == me.id) stringResource(R.string.expense_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, partner.displayName)
if (bearer == me.id) stringResource(DesignR.string.editor_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, partner.displayName)
}
}
}
@@ -86,6 +86,7 @@ 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.splitModeLabel
import se.hagstedt.kvitt.design.component.viewerPercentOf
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.design.icon.KvittIcon
@@ -354,9 +355,9 @@ internal fun EditorDialog(state: ExpenseEditorState, actions: ExpenseEditorActio
val confirm = stringResource(DesignR.string.common_discard)
val dismiss = stringResource(DesignR.string.common_keep_editing)
if (inline) {
ConfirmDialogContent(KvittIcon.Delete, title, "", confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true)
ConfirmDialogContent(KvittIcon.Delete, title, null, confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true)
} else {
ConfirmDialog(KvittIcon.Delete, title, "", confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true)
ConfirmDialog(KvittIcon.Delete, title, null, confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true)
}
}
EditorOverlay.Delete -> DeleteExpenseDialog(actions::confirmDelete, actions::dismissOverlay, inline)
@@ -399,7 +400,7 @@ private fun LocalizedDatePicker(date: String, today: String, actions: ExpenseEdi
)
val confirm: @Composable () -> Unit = {
TextButton({ picker.selectedDateMillis?.let { actions.onDate(LocalDate.fromEpochDays(it / DAY_MILLIS).toString()) } ?: actions.dismissOverlay() }) {
Text(stringResource(android.R.string.ok))
Text(stringResource(DesignR.string.common_ok))
}
}
val dismiss: @Composable () -> Unit = { TextButton(actions::dismissOverlay) { Text(stringResource(DesignR.string.common_cancel)) } }
@@ -426,8 +427,8 @@ private fun ErrorLine(text: String) {
/** The viewer's percent of the account's default ratio (bp in `Account.defaultShares`, §14.2.4). */
private fun accountViewerPercent(state: ExpenseEditorState): Int {
val me = state.me ?: return HALF
val bp = state.account?.defaultShares?.get(me.id) ?: return HALF
return (bp / PERCENT_HUNDREDTHS).toInt()
val account = state.account ?: return HALF
return viewerPercentOf(account.defaultShares, me.id)
}
@Composable
@@ -442,14 +443,14 @@ private fun modeChipLabel(state: ExpenseEditorState): String {
SplitMode.RATIO -> {
val language = kvittLanguage()
val viewer = form.ratioViewer.ore ?: return splitModeLabel(form.mode, "")
val ratio = stringResource(DesignR.string.common_ratio, form.ratioViewer.display(language), AmountInput.of(AmountInput.MAX_PERCENT_HUNDREDTHS - viewer).display(language))
val ratio = stringResource(DesignR.string.common_ratio, form.ratioViewer.display(language), AmountInput.percent(AmountInput.MAX_PERCENT_HUNDREDTHS - viewer).display(language))
splitModeLabel(form.mode, "") + " " + ratio
}
SplitMode.EXACT -> splitModeLabel(form.mode, "")
SplitMode.FULL -> {
val partner = state.partner
when {
form.fullOn == state.me?.id -> stringResource(R.string.expense_split_full_on_you)
form.fullOn == state.me?.id -> stringResource(DesignR.string.editor_split_full_on_you)
partner == null -> splitModeLabel(form.mode, "")
else -> stringResource(DesignR.string.editor_split_full_on, partner.displayName)
}
@@ -197,16 +197,16 @@ class ExpenseEditorViewModel @Inject constructor(
id = draft.id,
accountId = draft.accountId,
categoryId = draft.categoryId,
amount = draft.amountOre?.let(::moneyInput) ?: AmountInput(),
amount = draft.amountOre?.let(AmountInput::of) ?: AmountInput(),
description = draft.description,
notes = draft.notes,
date = draft.date,
payer = draft.payer,
mode = draft.mode,
ratioViewer = viewerBp?.let { AmountInput.of(it.toLong()) } ?: AmountInput(),
ratioPartner = viewerBp?.let { AmountInput.of(BP_TOTAL - it) } ?: AmountInput(),
ratioViewer = viewerBp?.let { AmountInput.percent(it.toLong()) } ?: AmountInput(),
ratioPartner = viewerBp?.let { AmountInput.percent(BP_TOTAL - it) } ?: AmountInput(),
exactTyped = me,
exactInput = exactMine?.let(::moneyInput) ?: AmountInput(),
exactInput = exactMine?.let(AmountInput::of) ?: AmountInput(),
fullOn = draft.fullOn ?: if (draft.payer == me) partner else me,
)
ui.update {
@@ -255,8 +255,9 @@ class ExpenseEditorViewModel @Inject constructor(
val choices = options.accounts.map { a -> AccountChoice(summaries[a.id] ?: AccountSummary(a, 0, 0), colorIndex[a.id] ?: 0) }
val account = form.accountId?.let { id -> options.accounts.firstOrNull { it.id == id } ?: env.all.firstOrNull { it.id == id } }
val active = options.categories.filter { !it.archived }.sortedBy { it.sortOrder }
// D-059/D-060: the account's used categories, filled up to five from sort_order; the selected one takes the fifth place.
val frequent = u.frequent.ifEmpty { active.take(QUICK_ROW).map { it.id } }
// D-059/D-060: the account's used categories as the repository gives them; quickRowCategories fills up to five
// from sort_order and puts the selected one fifth.
val frequent = u.frequent
return ExpenseEditorState(
ready = true,
isEdit = form.id != null,
@@ -306,7 +307,7 @@ class ExpenseEditorViewModel @Inject constructor(
KeypadTarget.RatioViewer, KeypadTarget.RatioPartner -> {
val typed = if (target == KeypadTarget.RatioViewer) form.ratioViewer else form.ratioPartner
val next = typed.press(key, AmountInput.MAX_PERCENT_HUNDREDTHS) ?: return false
val other = next.ore?.let { AmountInput.of(AmountInput.MAX_PERCENT_HUNDREDTHS - it) } ?: AmountInput()
val other = next.ore?.let { AmountInput.percent(AmountInput.MAX_PERCENT_HUNDREDTHS - it) } ?: AmountInput()
editForm {
if (target == KeypadTarget.RatioViewer) it.copy(ratioViewer = next, ratioPartner = other) else it.copy(ratioPartner = next, ratioViewer = other)
}
@@ -375,7 +376,7 @@ class ExpenseEditorViewModel @Inject constructor(
val form = u.form ?: return@update u
val next = when (mode) {
SplitMode.DEFAULT -> form.copy(mode = mode)
SplitMode.RATIO -> form.copy(mode = mode, ratioViewer = AmountInput.of(viewerBp), ratioPartner = AmountInput.of(BP_TOTAL - viewerBp))
SplitMode.RATIO -> form.copy(mode = mode, ratioViewer = AmountInput.percent(viewerBp), ratioPartner = AmountInput.percent(BP_TOTAL - viewerBp))
SplitMode.EXACT -> form.copy(mode = mode, exactTyped = me, exactInput = AmountInput())
SplitMode.FULL -> form.copy(mode = mode, fullOn = if (form.payer == me) partner else me)
}
@@ -394,7 +395,7 @@ class ExpenseEditorViewModel @Inject constructor(
/** Ratio slider and presets: whole percent for the viewer. */
override fun onViewerPercent(percent: Int) {
val bp = percent.coerceIn(0, PERCENT) * PERCENT.toLong()
editForm { it.copy(ratioViewer = AmountInput.of(bp), ratioPartner = AmountInput.of(BP_TOTAL - bp)) }
editForm { it.copy(ratioViewer = AmountInput.percent(bp), ratioPartner = AmountInput.percent(BP_TOTAL - bp)) }
clearError(FIELD_SPLIT)
}
@@ -525,15 +526,8 @@ class ExpenseEditorViewModel @Inject constructor(
private const val BP_TOTAL = 10_000L
private const val HALF_BP = 5000
private const val PERCENT = 100
private const val QUICK_ROW = 5
private val SPLIT_TARGETS = setOf(KeypadTarget.RatioViewer, KeypadTarget.RatioPartner, KeypadTarget.Exact)
/** A stored amount as editable text with both decimals when it has öre: 124 760 -> "1247.60", 41 200 -> "412". */
fun moneyInput(ore: Long): AmountInput {
val cents = ore % ORE_PER_KRONA
return AmountInput(if (cents == 0L) (ore / ORE_PER_KRONA).toString() else "${ore / ORE_PER_KRONA}." + cents.toString().padStart(2, '0'))
}
private const val ORE_PER_KRONA = 100L
/** The draft of §14.2.4 from the form: exact derives the other share as amount minus the typed one (AC-8.7). */
@@ -19,7 +19,5 @@
<string name="expense_detail_deleted_body">Den räknas inte längre i saldon eller statistik.</string>
<string name="expense_detail_missing">Utgiften finns inte på telefonen än</string>
<string name="expense_detail_missing_body">Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den.</string>
<!-- Not in strings.csv yet (proposed in the M7c report): S-8 "Synka nu" and the split label "Allt på dig". -->
<string name="expense_detail_sync_now">Synka nu</string>
<string name="expense_split_full_on_you">Allt på dig</string>
</resources>
@@ -19,7 +19,5 @@
<string name="expense_detail_deleted_body">It no longer counts in balances or analytics.</string>
<string name="expense_detail_missing">This expense is not on the phone yet</string>
<string name="expense_detail_missing_body">It may have just been added on another phone. Sync to fetch it.</string>
<!-- Not in strings.csv yet (proposed in the M7c report): S-8 "Synka nu" and the split label "Allt på dig". -->
<string name="expense_detail_sync_now">Sync now</string>
<string name="expense_split_full_on_you">All on you</string>
</resources>
@@ -17,6 +17,7 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
@@ -269,8 +270,9 @@ class ExpenseEditorViewModelTest {
fun editedAmounts_keepBothDecimals() = runTest(dispatcher) {
val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.icaId.value))
assertEquals("1247.60", vm.state.value.form?.amount?.raw)
assertEquals("412", ExpenseEditorViewModel.moneyInput(41_200).raw)
assertEquals("0.05", ExpenseEditorViewModel.moneyInput(5).raw)
// The feature's own moneyInput is gone: :core:design's AmountInput.of restores money with both decimals (M6b).
assertEquals("412.00", AmountInput.of(41_200).raw)
assertEquals("0.05", AmountInput.of(5).raw)
}
@Test
@@ -53,6 +53,7 @@ import se.hagstedt.kvitt.design.component.Skeleton
import se.hagstedt.kvitt.design.component.SyncStatusPill
import se.hagstedt.kvitt.design.component.UpdateBanner
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.styledStringResource
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
@@ -174,7 +175,7 @@ private fun HomeList(ui: HomeUi.Ready, partner: User, actions: HomeActions, padd
item(key = "hero") { BalanceHero(HeroState.Loading, ui.home.me, words, actions.onSettleUp) }
item(key = "pill") {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Skeleton(spacing.s1, KvittDimens.pillMinHeight, Modifier.fillMaxWidth(SKELETON_PILL_FRACTION), pill = true)
Skeleton(KvittDimens.skeletonPillWidth, KvittDimens.pillMinHeight, pill = true)
}
}
item(key = "header") { SectionHeader(stringResource(R.string.home_accounts)) }
@@ -234,7 +235,6 @@ private fun HomeList(ui: HomeUi.Ready, partner: User, actions: HomeActions, padd
}
private const val SKELETON_CARDS = 3
private const val SKELETON_PILL_FRACTION = 0.5f
@Composable
private fun UpdateBannerItem(ui: HomeUi.Ready, actions: HomeActions) {
@@ -252,8 +252,7 @@ private fun HomeAccountCard(summary: AccountSummary, partner: User, colorIndex:
private fun expensesLine(count: Int): String {
val today = LocalKvittClock.current.today
val period = today.year.toString() + "-" + today.month.ordinal.plus(1).toString().padStart(2, '0')
// monthYear is "oktober 2026" / "October 2026"; running text needs the month alone.
val month = DateFormatter.monthYear(period, kvittLanguage()).substringBeforeLast(' ')
val month = DateFormatter.monthName(period, kvittLanguage())
return if (count > 0) pluralStringResource(R.plurals.home_account_expenses_month, count, count, month) else stringResource(R.string.home_account_no_expenses_month, month)
}
@@ -263,8 +262,8 @@ private fun WelcomeCard(me: User, partner: User) {
KvittCard(Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.cardTileGap)) {
Box {
PersonAvatar(me, KvittDimens.accountTile)
PersonAvatar(partner, KvittDimens.accountTile, Modifier.padding(start = KvittDimens.accountTile - KvittTheme.spacing.s2))
PersonAvatar(me, KvittDimens.welcomeAvatar)
PersonAvatar(partner, KvittDimens.welcomeAvatar, Modifier.padding(start = KvittDimens.welcomeAvatar - KvittDimens.welcomeAvatarOverlap))
}
Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Text(stringResource(R.string.home_empty_welcome, partner.displayName), style = MaterialTheme.typography.titleMedium)
@@ -280,7 +279,8 @@ private fun InstallDialog(actions: HomeActions) {
ConfirmDialog(
icon = KvittIcon.InstallMobile,
title = stringResource(DesignR.string.update_permission_title),
body = stringResource(DesignR.string.update_permission_body),
body = null,
styledBody = styledStringResource(DesignR.string.update_permission_body),
confirm = stringResource(DesignR.string.common_continue),
dismiss = stringResource(DesignR.string.update_later),
onConfirm = actions.onInstallContinue,
@@ -57,8 +57,6 @@
<!-- opens the system date settings -->
<string name="home_sync_status_open_date_settings">Datum och tid</string>
<string name="home_sync_status_last_attempt_clock">Senaste försöket nekades: klockan</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_expanded">utfälld</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_collapsed">hopfälld</string>
</resources>
@@ -57,8 +57,6 @@
<!-- opens the system date settings -->
<string name="home_sync_status_open_date_settings">Date &amp; time</string>
<string name="home_sync_status_last_attempt_clock">Last attempt refused: the clock</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_expanded">expanded</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_collapsed">collapsed</string>
</resources>
@@ -1,41 +0,0 @@
package se.hagstedt.kvitt.feature.onboarding
import androidx.compose.ui.unit.dp
/**
* Measures of design-spec §4 S-1, S-2 and S-18 that `:core:design`'s `KvittDimens` does not name yet. Kept in this one
* file so they move to `KvittDimens` in one step (contract request in the M7a report); nothing else in this module
* writes a size.
*/
internal object OnboardingDimens {
// S-1 brand block: padding 28 / 0 / 12, app mark 88 with `receipt_long` 44.
val brandTop = 28.dp
val brandBottom = 12.dp
val appMark = 88.dp
val appMarkIcon = 44.dp
// S-1 column gap 14; bottom bar padding 12 / 16; progress indicator in the button 20 (stroke 2.5).
val columnGap = 14.dp
val bottomBarVertical = 12.dp
val bottomBarHorizontal = 16.dp
val buttonProgress = 20.dp
val buttonProgressStroke = 2.5.dp
// S-1 help line icon 20.
val helpIcon = 20.dp
// S-2 intro padding 24 / 4 / 4; card padding 22 / 20, gap 14; avatar badge 40 with icon 20 and a 3 dp card ring.
val introTop = 24.dp
val introSide = 4.dp
val introBottom = 4.dp
val cardVertical = 22.dp
val cardHorizontal = 20.dp
val avatarBadge = 40.dp
val avatarBadgeIcon = 20.dp
val avatarBadgeRing = 3.dp
// S-18: padding top 80, gap 14, tile 112 with `phonelink_erase` 52.
val removedTop = 80.dp
val removedTile = 112.dp
val removedIcon = 52.dp
}
@@ -1,27 +1,11 @@
package se.hagstedt.kvitt.feature.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.ButtonSize
import se.hagstedt.kvitt.design.component.KvittButton
@@ -35,40 +19,15 @@ internal fun BottomActionBar(content: @Composable () -> Unit) {
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = OnboardingDimens.bottomBarHorizontal, vertical = OnboardingDimens.bottomBarVertical),
.padding(horizontal = KvittDimens.bottomBarHorizontal, vertical = KvittDimens.bottomBarVertical),
) { content() }
}
/** The full-width filled medium button of the bottom bars. */
@Composable
internal fun BottomButton(text: String, onClick: () -> Unit, icon: KvittIcon?, enabled: Boolean = true) {
KvittButton(text, onClick, Modifier.fillMaxWidth(), kind = ButtonKind.Filled, size = ButtonSize.Medium, icon = icon, enabled = enabled)
}
/**
* S-1's "Para ihop" while pairing (design-spec S-1 "loading"): the filled medium button keeps its `primary` fill and
* shows a 20 dp progress indicator in `on-primary` with "Parar ihop…"; it is not clickable. `KvittButton` has no
* progress form (contract request in the M7a report), so this draws the same button with Material's `Button`.
* The full-width filled medium button of the bottom bars. [loading] is S-1's "Parar ihop…" (design-spec S-1
* "loading"): the fill kept, a 20 dp progress indicator in `on-primary`, not clickable ([KvittButton]'s loading form).
*/
@Composable
internal fun ProgressBottomButton(text: String) {
val scheme = MaterialTheme.colorScheme
Button(
onClick = {},
modifier = Modifier.fillMaxWidth().heightIn(min = KvittDimens.buttonMedium),
enabled = true,
shape = CircleShape,
colors = ButtonDefaults.buttonColors(containerColor = scheme.primary, contentColor = scheme.onPrimary),
contentPadding = PaddingValues(horizontal = KvittDimens.buttonMediumHorizontal),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator(
Modifier.size(OnboardingDimens.buttonProgress),
color = scheme.onPrimary,
strokeWidth = OnboardingDimens.buttonProgressStroke,
)
Spacer(Modifier.width(KvittTheme.spacing.s2))
Text(text, style = KvittTheme.typography.buttonMedium, textAlign = TextAlign.Center, maxLines = 2)
}
}
internal fun BottomButton(text: String, onClick: () -> Unit, icon: KvittIcon?, enabled: Boolean = true, loading: Boolean = false) {
KvittButton(text, onClick, Modifier.fillMaxWidth(), kind = ButtonKind.Filled, size = ButtonSize.Medium, icon = icon, enabled = enabled, loading = loading)
}
@@ -130,7 +130,7 @@ fun PairingContent(
bottomBar = {
BottomActionBar {
if (state.pairing) {
ProgressBottomButton(stringResource(R.string.onboarding_pairing))
BottomButton(stringResource(R.string.onboarding_pairing), {}, KvittIcon.Link, loading = true)
} else {
BottomButton(stringResource(R.string.onboarding_pair), actions.onPair, KvittIcon.Link, enabled = state.canPair)
}
@@ -143,7 +143,7 @@ fun PairingContent(
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter),
verticalArrangement = Arrangement.spacedBy(OnboardingDimens.columnGap),
verticalArrangement = Arrangement.spacedBy(KvittDimens.onboardingGap),
) {
Brand(state.mode)
val codeError = when (state.error) {
@@ -160,6 +160,8 @@ fun PairingContent(
modifier = Modifier.announced(codeError),
error = codeError,
enabled = !state.pairing,
placeholder = stringResource(R.string.onboarding_code_placeholder),
imeAction = if (state.nameRequired) ImeAction.Next else ImeAction.Done,
)
if (state.nameRequired) {
KvittTextField(
@@ -211,15 +213,15 @@ private fun Modifier.announced(error: String?): Modifier =
private fun Brand(mode: PairingMode) {
val scheme = MaterialTheme.colorScheme
Column(
Modifier.fillMaxWidth().padding(top = OnboardingDimens.brandTop, bottom = OnboardingDimens.brandBottom),
Modifier.fillMaxWidth().padding(top = KvittDimens.brandTop, bottom = KvittDimens.brandBottom),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
Box(
Modifier.size(OnboardingDimens.appMark).background(scheme.primary, KvittTheme.shapes.avatarLarge),
Modifier.size(KvittDimens.appMark).background(scheme.primary, KvittTheme.shapes.avatarLarge),
contentAlignment = Alignment.Center,
) {
KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = OnboardingDimens.appMarkIcon, tint = scheme.onPrimary)
KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = KvittDimens.appMarkIcon, tint = scheme.onPrimary)
}
Text(stringResource(DesignR.string.common_wordmark), style = MaterialTheme.typography.displaySmall, color = scheme.primary)
if (mode == PairingMode.Again) {
@@ -252,7 +254,7 @@ private fun HelpLine() {
Modifier.fillMaxWidth().padding(horizontal = KvittTheme.spacing.s2),
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
KvittIconImage(KvittIcon.Info, null, size = OnboardingDimens.helpIcon, tint = muted)
KvittIconImage(KvittIcon.Info, null, size = KvittDimens.helpIcon, tint = muted)
Text(stringResource(R.string.onboarding_code_help_generic), style = MaterialTheme.typography.bodyMedium, color = muted)
}
}
@@ -169,10 +169,10 @@ private fun Intro(owner: User) {
val ownerName = owner.displayName
Column(
Modifier.fillMaxWidth().padding(
start = OnboardingDimens.introSide,
end = OnboardingDimens.introSide,
top = OnboardingDimens.introTop,
bottom = OnboardingDimens.introBottom,
start = KvittDimens.profileIntroSide,
end = KvittDimens.profileIntroSide,
top = KvittDimens.profileIntroTop,
bottom = KvittDimens.profileIntroBottom,
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
@@ -197,9 +197,9 @@ private fun ProfileCard(state: ProfileStepUiState, actions: ProfileStepActions)
val me = state.me ?: return
KvittCard(
Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = OnboardingDimens.cardHorizontal, vertical = OnboardingDimens.cardVertical),
contentPadding = PaddingValues(horizontal = KvittDimens.profileCardHorizontal, vertical = KvittDimens.profileCardVertical),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OnboardingDimens.columnGap),
verticalArrangement = Arrangement.spacedBy(KvittDimens.onboardingGap),
) {
Box {
PersonAvatar(
@@ -212,14 +212,14 @@ private fun ProfileCard(state: ProfileStepUiState, actions: ProfileStepActions)
Box(
Modifier
.align(Alignment.BottomEnd)
.size(OnboardingDimens.avatarBadge)
.border(OnboardingDimens.avatarBadgeRing, KvittTheme.colors.card, CircleShape)
.padding(OnboardingDimens.avatarBadgeRing)
.size(KvittDimens.avatarBadge)
.border(KvittDimens.avatarBadgeRing, KvittTheme.colors.card, CircleShape)
.padding(KvittDimens.avatarBadgeRing)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.clearAndSetSemantics {},
contentAlignment = Alignment.Center,
) {
KvittIconImage(KvittIcon.AddAPhoto, null, size = OnboardingDimens.avatarBadgeIcon, tint = MaterialTheme.colorScheme.onPrimary)
KvittIconImage(KvittIcon.AddAPhoto, null, size = KvittDimens.avatarBadgeIcon, tint = MaterialTheme.colorScheme.onPrimary)
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
@@ -26,6 +26,7 @@ import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
/** S-18 Device removed (design-spec S-18, AC-3.3). [onPairAgain] opens S-1 after the revocation is acknowledged. */
@Composable
@@ -57,15 +58,15 @@ fun RemovedContent(onPairAgain: () -> Unit, modifier: Modifier = Modifier) {
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, top = OnboardingDimens.removedTop),
.padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, top = KvittDimens.removedTop),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OnboardingDimens.columnGap),
verticalArrangement = Arrangement.spacedBy(KvittDimens.onboardingGap),
) {
Box(
Modifier.size(OnboardingDimens.removedTile).background(scheme.errorContainer, KvittTheme.shapes.illustration),
Modifier.size(KvittDimens.removedTile).background(scheme.errorContainer, KvittTheme.shapes.illustration),
contentAlignment = Alignment.Center,
) {
KvittIconImage(KvittIcon.PhonelinkErase, null, size = OnboardingDimens.removedIcon, tint = scheme.onErrorContainer)
KvittIconImage(KvittIcon.PhonelinkErase, null, size = KvittDimens.removedIcon, tint = scheme.onErrorContainer)
}
Text(
stringResource(R.string.onboarding_removed_title),
@@ -93,8 +93,8 @@ fun AboutContent(state: AboutScreenState?, actions: AboutActions, updateCheckAva
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
Column(Modifier.fillMaxWidth().padding(vertical = KvittTheme.spacing.s4), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
Box(Modifier.size(KvittTheme.spacing.s12 * 2).background(MaterialTheme.colorScheme.primary, KvittTheme.shapes.avatarLarge).clearAndSetSemantics { }, contentAlignment = Alignment.Center) {
KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = KvittTheme.spacing.s12, tint = MaterialTheme.colorScheme.onPrimary)
Box(Modifier.size(KvittDimens.aboutAppMark).background(MaterialTheme.colorScheme.primary, KvittTheme.shapes.avatarLarge).clearAndSetSemantics { }, contentAlignment = Alignment.Center) {
KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = KvittDimens.aboutAppMarkIcon, tint = MaterialTheme.colorScheme.onPrimary)
}
Text(stringResource(R.string.settings_about_version, about.versionName, about.versionCode), style = MaterialTheme.typography.headlineMedium, textAlign = TextAlign.Center)
Text(stringResource(DesignR.string.common_tagline), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -246,8 +246,8 @@ private fun EditorBody(state: RecurringEditorState, actions: RecurringEditorActi
}
SplitMode.RATIO -> {
val viewerTyped = form.ratioTyped == RatioField.Viewer
val viewerText = if (viewerTyped) form.ratioInput.display(language) else AmountInput.of(form.viewerBp.toLong()).display(language)
val partnerText = if (viewerTyped) AmountInput.of((10_000 - form.viewerBp).toLong()).display(language) else form.ratioInput.display(language)
val viewerText = if (viewerTyped) form.ratioInput.display(language) else AmountInput.percent(form.viewerBp.toLong()).display(language)
val partnerText = if (viewerTyped) AmountInput.percent((10_000 - form.viewerBp).toLong()).display(language) else form.ratioInput.display(language)
val focused = when (form.focus) {
EditorFocus.RatioViewer -> RatioField.Viewer
EditorFocus.RatioPartner -> RatioField.Partner
@@ -370,7 +370,7 @@ private fun EditorDialogs(state: RecurringEditorState, actions: RecurringEditorA
EditorDialog.Discard -> ConfirmDialog(
icon = KvittIcon.Close,
title = stringResource(DesignR.string.common_discard_title),
body = "",
body = null,
confirm = stringResource(DesignR.string.common_discard),
dismiss = stringResource(DesignR.string.common_keep_editing),
onConfirm = actions.onDiscard,
@@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.component.AccountChoice
import se.hagstedt.kvitt.design.component.RatioField
import se.hagstedt.kvitt.design.component.quickRowCategories
import se.hagstedt.kvitt.design.component.viewerPercentOf
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.feature.settings.common.UiMessage
@@ -64,7 +65,7 @@ data class RecurringEditorState(
val canSave: Boolean get() = online && !busy && changed && valid && !errors.any
/** The account's default ratio for "Kontots standard 60/40", viewer first. */
val viewerDefaultPercent: Int get() = (account?.defaultShares?.get(options.me.id) ?: BP_TOTAL / 2) / 100
val viewerDefaultPercent: Int get() = viewerPercentOf(account?.defaultShares.orEmpty(), options.me.id)
val dayClampHint: Boolean get() = (form.dayOfMonth ?: 0) >= 29
val resumeHint: Boolean get() = original?.paused == true && !form.paused
}
@@ -139,7 +139,7 @@ data class RecurringForm(
categoryPicked = true,
payer = d.payer,
mode = d.mode,
ratioInput = AmountInput.of((viewerBp ?: BP_TOTAL / 2).toLong()),
ratioInput = AmountInput.percent((viewerBp ?: BP_TOTAL / 2).toLong()),
exactTyped = typed,
exactInput = d.exactOre?.get(typed)?.let { AmountInput.of(it) } ?: AmountInput(),
fullOn = d.fullOn ?: me,
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Keys marked PROPOSED are
not in strings.csv yet; the M7f report proposes them. Generated; change the CSV first. -->
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
first. -->
<resources>
<string name="settings_profile_offline_banner">Kräver anslutning. Profilen kan bara ändras när telefonen är online.</string>
<string name="settings_title">Inställningar</string>
@@ -179,16 +179,10 @@
<string name="settings_about_server_version">Serverversion</string>
<string name="settings_about_server_version_unknown">Okänd</string>
<string name="settings_about_licences">Licenser för öppen källkod</string>
<!-- PROPOSED (M7f report): S-15 snackbar after Arkivera; %1$s category name -->
<string name="settings_category_archived_snackbar">%1$s arkiverades</string>
<!-- PROPOSED (M7f report): S-15 snackbar after Återställ från arkiv -->
<string name="settings_category_unarchived_snackbar">%1$s återställdes</string>
<!-- PROPOSED (M7f report): S-13 recurring row subtitle without templates (design-spec S-13) -->
<string name="settings_recurring_summary_none">Inga</string>
<!-- PROPOSED (M7f report): TalkBack state of the archived section (design-spec §3.11 CollapsibleRow) -->
<string name="settings_expanded">utfälld</string>
<!-- PROPOSED (M7f report): TalkBack state of the archived section -->
<string name="settings_collapsed">hopfälld</string>
<!-- PROPOSED (M7f report): S-13 partner row before the partner joins -->
<string name="settings_partner_missing">Partner saknas</string>
</resources>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Keys marked PROPOSED are
not in strings.csv yet; the M7f report proposes them. Generated; change the CSV first. -->
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
first. -->
<resources>
<string name="settings_profile_offline_banner">Needs connection. Your profile can only be changed online.</string>
<string name="settings_title">Settings</string>
@@ -179,16 +179,10 @@
<string name="settings_about_server_version">Server version</string>
<string name="settings_about_server_version_unknown">Unknown</string>
<string name="settings_about_licences">Open-source licences</string>
<!-- PROPOSED (M7f report): S-15 snackbar after Arkivera; %1$s category name -->
<string name="settings_category_archived_snackbar">%1$s was archived</string>
<!-- PROPOSED (M7f report): S-15 snackbar after Återställ från arkiv -->
<string name="settings_category_unarchived_snackbar">%1$s was restored</string>
<!-- PROPOSED (M7f report): S-13 recurring row subtitle without templates (design-spec S-13) -->
<string name="settings_recurring_summary_none">None</string>
<!-- PROPOSED (M7f report): TalkBack state of the archived section (design-spec §3.11 CollapsibleRow) -->
<string name="settings_expanded">expanded</string>
<!-- PROPOSED (M7f report): TalkBack state of the archived section -->
<string name="settings_collapsed">collapsed</string>
<!-- PROPOSED (M7f report): S-13 partner row before the partner joins -->
<string name="settings_partner_missing">Partner has not joined</string>
</resources>
@@ -97,7 +97,7 @@ class RecurringScreenshotTest(private val state: String) {
editor("edit_exact_over", SettingsScenarios.loaded, SettingsScenarios.netflix) { _, vm ->
vm.toggleSplit()
vm.focus(EditorFocus.Exact)
repeat(4) { vm.onKey(AmountKey.Delete) }
vm.onKey(AmountKey.Clear)
vm.type("200")
},
editor("edit_full", SettingsScenarios.loaded, SettingsScenarios.spotify) { _, vm -> vm.toggleSplit() },
@@ -125,7 +125,7 @@ fun SettleContent(state: SettleUiState, actions: SettleActions, modifier: Modifi
ConfirmDialogContent(
icon = KvittIcon.Delete,
title = stringResource(DesignR.string.common_discard_title),
body = "",
body = null,
confirm = stringResource(DesignR.string.common_discard),
dismiss = stringResource(DesignR.string.common_keep_editing),
onConfirm = actions::onDiscard,
@@ -457,7 +457,7 @@ private fun SettleDatePicker(isoDate: String, actions: SettleActions) {
DatePickerDialog(
onDismissRequest = actions::onDismissDialog,
confirmButton = {
KvittButton(resources.getString(DesignR.string.common_save), {
KvittButton(resources.getString(DesignR.string.common_ok), {
picker.selectedDateMillis?.let { actions.onDate(LocalDate.fromEpochDays(it.floorDiv(MILLIS_PER_DAY)).toString()) } ?: actions.onDismissDialog()
}, kind = ButtonKind.Text)
},
@@ -119,6 +119,7 @@ fun SettlementDetailContent(
detail == null -> EmptyState(
title = stringResource(R.string.settle_detail_missing),
body = stringResource(R.string.settle_detail_missing_body),
icon = KvittIcon.SyncProblem,
action = stringResource(R.string.settle_detail_sync_now),
actionIcon = KvittIcon.Sync,
actionKind = ButtonKind.Tonal,
@@ -210,13 +211,13 @@ private fun Hero(detail: SettlementDetailState, modifier: Modifier) {
KvittCard(
modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = spoken },
color = KvittTheme.colors.settlementHero,
contentPadding = PaddingValues(horizontal = KvittTheme.spacing.s5, vertical = KvittTheme.spacing.s5),
contentPadding = PaddingValues(horizontal = KvittDimens.settlementHeroHorizontal, vertical = KvittDimens.settlementHeroVertical),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
PersonAvatar(detail.from, KvittDimens.avatarSettings, shape = AvatarShape.Large)
KvittIconImage(KvittIcon.ArrowForward, null, size = KvittDimens.accountHeaderSettledIcon, tint = MaterialTheme.colorScheme.onSurfaceVariant)
KvittIconImage(KvittIcon.ArrowForward, null, size = KvittDimens.settlementArrow, tint = MaterialTheme.colorScheme.onSurfaceVariant)
PersonAvatar(detail.to, KvittDimens.avatarSettings, shape = AvatarShape.Large)
}
Text(text, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle; the last three keys are
proposed in the M7d report and not yet in strings.csv. -->
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle. -->
<resources>
<string name="settle_title">Gör upp</string>
<string name="settle_title_edit">Ändra betalning</string>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle; the last three keys are
proposed in the M7d report and not yet in strings.csv. -->
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle. -->
<resources>
<string name="settle_title">Settle up</string>
<string name="settle_title_edit">Edit payment</string>