Android overhaul: shared failure texts, saved-split label and limits in :core:design

* format/Failures.kt: FAILURE_NETWORK and commonFailureText, the §14.3.2 code-to-text map that :feature:account
  (Errors.kt) and :feature:settings (Messages.kt) each spelled out; both keep only their own codes. The "network"
  literals in :feature:onboarding and the two NETWORK constants use FAILURE_NETWORK.
* component/Split.kt: savedSplitLabel, the S-8 and S-16 line ("Kontots standard 50/50", "Egen fördelning 70/30",
  "Exakta belopp", "Allt på dig/Anna") that ExpenseDetailScreen.modeLabel and SyncStatusScreen.lostModeLabel
  duplicated line for line.
* format/Limits.kt: BP_TOTAL (five copies in four features and a literal in :core:design and S-19) and
  DISPLAY_NAME_MAX (three copies in :feature:onboarding and :feature:settings).

Behaviour and goldens unchanged.

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 23:04:30 +02:00
parent 8229ed60ea
commit 515b60fc65
24 changed files with 132 additions and 128 deletions
@@ -45,6 +45,7 @@ import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.ratioSpoken
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.format.BP_TOTAL
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.model.AccountId
@@ -215,7 +216,6 @@ private fun defaultSplit(ui: AccountDetailUi.Ready): String {
return stringResource(R.string.account_default_split, ratio, names)
}
internal const val BP_TOTAL = 10_000
/** `more_vert` with the S-5 menu; disabled items carry their reason as a second line (design-spec §3.11). */
@Composable
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.model.AccountDetailState
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
@@ -170,10 +171,7 @@ class AccountDetailViewModel @Inject constructor(
}
private fun failureMessage(code: String, action: AccountAction): AccountMessage = when (code) {
NETWORK -> AccountMessage.CouldNotReach(action)
FAILURE_NETWORK -> AccountMessage.CouldNotReach(action)
"account_not_empty" -> AccountMessage.DeleteBlocked
else -> AccountMessage.Failed(code)
}
/** `OnlineResult.Failed` code of a network failure (design-spec §1, §9.9). */
internal const val NETWORK = "network"
@@ -54,6 +54,7 @@ import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.money
import se.hagstedt.kvitt.design.component.ratioSpoken
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.format.BP_TOTAL
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
@@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.format.BP_TOTAL
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
@@ -183,7 +185,7 @@ private fun AccountEditorUi.failed(code: String, field: String?): AccountEditorU
code == "name_taken" -> copy(saving = false, serverNameError = NameError.Taken(name.trim()))
code == "validation_failed" && field == "name" -> copy(saving = false, serverNameError = NameError.Invalid)
code == "validation_failed" && field == "default_shares" -> copy(saving = false, splitError = true)
code == NETWORK -> copy(saving = false, message = EditorMessage.CouldNotReach)
code == FAILURE_NETWORK -> copy(saving = false, message = EditorMessage.CouldNotReach)
else -> copy(saving = false, message = EditorMessage.Failed(code))
}
@@ -1,19 +1,12 @@
package se.hagstedt.kvitt.feature.account
import androidx.annotation.StringRes
import se.hagstedt.kvitt.design.format.commonFailureText
import se.hagstedt.kvitt.core.design.R as DesignR
/** The snackbar text of an `OnlineResult.Failed` code without a field at fault (§14.3.2, strings.csv `error_*`). */
@StringRes
internal fun errorText(code: String): Int = when (code) {
NETWORK -> DesignR.string.common_could_not_reach_server
"household_incomplete" -> DesignR.string.error_household_incomplete
"not_found" -> DesignR.string.error_not_found
"forbidden" -> DesignR.string.error_forbidden
"rate_limited" -> DesignR.string.error_rate_limited
"protocol_unsupported" -> DesignR.string.error_app_too_old
"internal", "unavailable", "not_implemented" -> DesignR.string.error_server
"validation_failed" -> DesignR.string.error_validation
"account_not_empty" -> R.string.account_delete_blocked
else -> DesignR.string.error_generic_invalid
else -> commonFailureText(code) ?: DesignR.string.error_generic_invalid
}
@@ -8,6 +8,7 @@ import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.account.AccountScenarios
import se.hagstedt.kvitt.model.AccountId
@@ -28,7 +29,7 @@ val accountStates: List<ScreenState> = listOf(
detail("S-5-account-delete-confirm", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() },
detail("S-5-account-empty", AccountScenarios.empty, AccountScenarios.sommarstugan.id),
detail("S-5-account-offline", AccountScenarios.offline, AccountScenarios.vardag) { it.setMenuOpen(true) },
detail("S-5-account-error", AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive() },
detail("S-5-account-error", AccountScenarios.loaded, AccountScenarios.vardag, failWith = FAILURE_NETWORK) { it.archive() },
detail("S-5-account-long", AccountScenarios.long, AccountScenarios.longAccount, largeFont = true),
editor("S-6-account-editor-create", AccountScenarios.editor),
editor("S-6-account-editor", AccountScenarios.editor, largeFont = true) { it.onName("Sommarstugan"); it.onViewerPercent(60) },
@@ -36,7 +37,7 @@ val accountStates: List<ScreenState> = listOf(
editor("S-6-account-editor-decimals", AccountScenarios.editor) { it.onName("Sommarstugan"); it.onPercent(PercentField.Viewer, "62,75") },
editor("S-6-account-editor-error", AccountScenarios.editor) { it.onName("Vardag"); it.save() },
editor("S-6-account-editor-validation", AccountScenarios.editor) { it.onName(LONG_NAME) },
editor("S-6-account-editor-network", AccountScenarios.editor, failWith = NETWORK) { it.onName("Sommarstugan"); it.save() },
editor("S-6-account-editor-network", AccountScenarios.editor, failWith = FAILURE_NETWORK) { it.onName("Sommarstugan"); it.save() },
editor("S-6-account-editor-offline", AccountScenarios.editorOffline) { it.onName("Sommarstugan") },
saving(),
)
@@ -5,6 +5,7 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.account.AccountScenarios
@@ -65,11 +66,11 @@ class AccountDetailViewModelTest {
@Test
fun networkFailureOffersRetryAndChangesNothing() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive() })
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = FAILURE_NETWORK) { it.archive() })
assertEquals(AccountMessage.CouldNotReach(AccountAction.Archive), ui.message)
assertFalse(ui.detail.account.archived)
assertFalse(ui.busy)
val shown = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive(); it.messageShown() })
val shown = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = FAILURE_NETWORK) { it.archive(); it.messageShown() })
assertNull(shown.message)
}
@@ -190,7 +191,7 @@ class AccountEditorViewModelTest {
fun serverValidationMapsToTheField() {
assertEquals(NameError.Invalid, editorState(AccountScenarios.editor, failWith = "validation_failed", field = "name") { it.onName("X"); it.save() }.nameError)
assertTrue(editorState(AccountScenarios.editor, failWith = "validation_failed", field = "default_shares") { it.onName("X"); it.save() }.splitError)
assertEquals(EditorMessage.CouldNotReach, editorState(AccountScenarios.editor, failWith = NETWORK) { it.onName("X"); it.save() }.message)
assertEquals(EditorMessage.CouldNotReach, editorState(AccountScenarios.editor, failWith = FAILURE_NETWORK) { it.onName("X"); it.save() }.message)
assertEquals(EditorMessage.Failed("household_incomplete"), editorState(AccountScenarios.editor, failWith = "household_incomplete") { it.onName("X"); it.save() }.message)
}