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
@@ -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