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:
+3
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-7
@@ -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)
|
||||
}
|
||||
|
||||
+10
-16
@@ -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>
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user