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
@@ -9,9 +9,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalResources
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.design.format.commonFailureText
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.feature.settings.R
/**
* A snackbar the screen shows once (design-spec §1 conventions): [text] from resources with [args]; [retry] adds
@@ -36,35 +38,28 @@ fun OnlineResult<*>.failureCode(): String? = when (this) {
}
const val OFFLINE = "offline"
const val NETWORK = "network"
/**
* The words for a refused online call (design-spec §1, `strings.csv` `error_*`): network → "Kunde inte nå servern",
* offline → "Kräver anslutning", known §14.3.2 codes → their text, anything else → the generic validation text.
* The words for a refused online call (design-spec §1, `strings.csv` `error_*`): offline → "Kräver anslutning", this
* module's codes → their text, the shared ones through [commonFailureText] (network → "Kunde inte nå servern"),
* anything else → the generic validation text.
*/
@StringRes
fun failureText(code: String): Int = when (code) {
NETWORK -> DesignR.string.common_could_not_reach_server
OFFLINE -> DesignR.string.common_needs_connection
"household_full" -> DesignR.string.error_household_full
"household_incomplete" -> DesignR.string.error_household_incomplete
"forbidden" -> DesignR.string.error_forbidden
"not_found" -> DesignR.string.error_not_found
"rate_limited" -> DesignR.string.error_rate_limited
"internal", "unavailable", "not_implemented" -> DesignR.string.error_server
"protocol_unsupported" -> DesignR.string.error_app_too_old
"validation_failed", "invalid_request" -> DesignR.string.error_validation
"invalid_request" -> DesignR.string.error_validation
"image_invalid" -> DesignR.string.profile_picture_invalid
"payload_too_large" -> DesignR.string.profile_picture_too_large
"unsupported_media_type" -> DesignR.string.profile_picture_unsupported
"category_in_use" -> R.string.settings_category_delete_blocked_in_use
"category_is_default" -> R.string.settings_category_delete_blocked_default
"cannot_revoke_current_device" -> R.string.settings_devices_cannot_revoke_current
else -> DesignR.string.error_generic_invalid
else -> commonFailureText(code) ?: DesignR.string.error_generic_invalid
}
/** The snackbar for a failed call: network failures offer "Försök igen" when [canRetry]. */
fun failureMessage(code: String, canRetry: Boolean = true): UiMessage = UiMessage(failureText(code), retry = canRetry && code == NETWORK)
fun failureMessage(code: String, canRetry: Boolean = true): UiMessage = UiMessage(failureText(code), retry = canRetry && code == FAILURE_NETWORK)
/**
* Shows [message] once in [host] (design-spec §3.11: 4 s, 10 s with "Försök igen") through [showKvittSnackbar], so its
@@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.feature.settings.common.NETWORK
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.feature.settings.common.UiMessage
import se.hagstedt.kvitt.feature.settings.common.failureCode
import se.hagstedt.kvitt.feature.settings.common.failureMessage
@@ -76,7 +76,7 @@ class DevicesViewModel @Inject constructor(
val list = when (result) {
is OnlineResult.Ok -> DeviceList.Loaded(result.value.filter { d -> !d.revoked })
OnlineResult.Offline -> DeviceList.Unavailable(unreachable = false)
is OnlineResult.Failed -> DeviceList.Unavailable(unreachable = result.code == NETWORK)
is OnlineResult.Failed -> DeviceList.Unavailable(unreachable = result.code == FAILURE_NETWORK)
}
local.update { it.copy(list = list) }
}
@@ -10,6 +10,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.DISPLAY_NAME_MAX
import se.hagstedt.kvitt.feature.settings.common.UiMessage
import se.hagstedt.kvitt.feature.settings.common.failureCode
import se.hagstedt.kvitt.feature.settings.common.failureMessage
@@ -29,16 +30,13 @@ sealed interface NameError {
data object Refused : NameError // validation_failed on display_name from the server
}
/** §9.6 limit of a display name. */
const val NAME_MAX = 40
/** The local check of a display name, or null when it may be sent. */
fun nameError(name: String): NameError? {
val trimmed = name.trim()
val length = trimmed.codePointCount(0, trimmed.length)
return when {
length == 0 -> NameError.Required
length > NAME_MAX -> NameError.TooLong(NAME_MAX)
length > DISPLAY_NAME_MAX -> NameError.TooLong(DISPLAY_NAME_MAX)
else -> null
}
}
@@ -76,6 +76,7 @@ import se.hagstedt.kvitt.design.component.splitModeLabel
import se.hagstedt.kvitt.design.component.splitOption
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.design.format.BP_TOTAL
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.design.icon.KvittIcon
@@ -236,7 +237,7 @@ private fun EditorBody(state: RecurringEditorState, actions: RecurringEditorActi
accountOption(state.account?.name.orEmpty(), state.accountColorIndex) { actions.onOpenSheet(EditorSheet.Account) },
payerOption(payer, if (payer == me) partner else me, actions.onTogglePayer),
splitOption(
if (form.mode == SplitMode.RATIO) stringResource(DesignR.string.editor_split_mode_ratio) + " " + stringResource(DesignR.string.common_ratio, (form.viewerBp / 100).toString(), ((10_000 - form.viewerBp) / 100).toString()) else splitModeLabel(form.mode, defaultLabel),
if (form.mode == SplitMode.RATIO) stringResource(DesignR.string.editor_split_mode_ratio) + " " + stringResource(DesignR.string.common_ratio, (form.viewerBp / 100).toString(), ((BP_TOTAL - form.viewerBp) / 100).toString()) else splitModeLabel(form.mode, defaultLabel),
form.splitOpen,
actions.onToggleSplit,
),
@@ -252,7 +253,7 @@ 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.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 partnerText = if (viewerTyped) AmountInput.percent((BP_TOTAL - form.viewerBp).toLong()).display(language) else form.ratioInput.display(language)
val focused = when (form.focus) {
EditorFocus.RatioViewer -> RatioField.Viewer
EditorFocus.RatioPartner -> RatioField.Partner
@@ -3,6 +3,7 @@ package se.hagstedt.kvitt.feature.settings.recurring
import se.hagstedt.kvitt.design.component.RatioField
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.design.format.BP_TOTAL
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseDraft
@@ -17,7 +18,6 @@ enum class EditorFocus { None, Amount, RatioViewer, RatioPartner, Exact }
/** §9.6: description 1 to 100, notes 0 to 1000 code points. */
const val DESCRIPTION_MAX = 100
const val BP_TOTAL = 10_000
/**
* The S-19 editor's input, independent of the repositories: the expense fields of S-7 without the date (D-019) plus