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
@@ -2,6 +2,7 @@ package se.hagstedt.kvitt.feature.onboarding
import se.hagstedt.kvitt.design.component.INVITE_LENGTH
import se.hagstedt.kvitt.design.component.normalizeInviteCode
import se.hagstedt.kvitt.design.format.DISPLAY_NAME_MAX
import java.net.URI
import java.net.URISyntaxException
import java.net.URLDecoder
@@ -62,11 +63,10 @@ object InviteLink {
/** A display name of §9.6 (1 to 40 code points after trimming, no control characters), else null. */
private fun adminName(value: String?): String? {
val name = value?.trim() ?: return null
return name.takeIf { it.codePointCount(0, it.length) in 1..NAME_MAX && it.none(Char::isISOControl) }
return name.takeIf { it.codePointCount(0, it.length) in 1..DISPLAY_NAME_MAX && it.none(Char::isISOControl) }
}
private const val FROM = "from"
private const val NAME_MAX = 40
/** The normalised code when [text] normalises to exactly 12 alphabet characters (§8.3), else null. */
private fun code(text: String): String? {
@@ -40,15 +40,16 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.InviteCodeField
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTextField
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.format.DISPLAY_NAME_MAX
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.core.design.R as DesignR
/** What S-1 can be asked to do; the stateless [PairingContent] calls these. */
data class PairingActions(
@@ -172,7 +173,7 @@ fun PairingContent(
onValueChange = actions.onNameChange,
label = stringResource(R.string.onboarding_name_label),
error = if (state.nameTooLong) {
pluralStringResource(DesignR.plurals.error_too_long, PairingUiState.NAME_MAX, PairingUiState.NAME_MAX)
pluralStringResource(DesignR.plurals.error_too_long, DISPLAY_NAME_MAX, DISPLAY_NAME_MAX)
} else {
null
},
@@ -10,8 +10,10 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.component.INVITE_LENGTH
import se.hagstedt.kvitt.design.component.normalizeInviteCode
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.design.format.DISPLAY_NAME_MAX
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import javax.inject.Inject
@@ -63,16 +65,12 @@ data class PairingUiState(
val nameRequired: Boolean get() = mode == PairingMode.First
/** The display name is 1 to 40 code points after trimming (§9.6). */
val nameTooLong: Boolean get() = nameRequired && name.trim().let { it.codePointCount(0, it.length) } > NAME_MAX
val nameTooLong: Boolean get() = nameRequired && name.trim().let { it.codePointCount(0, it.length) } > DISPLAY_NAME_MAX
/** "Para ihop" is enabled when the code has 12 characters, the name is set (first pairing), the address is well formed. */
val canPair: Boolean
get() = !pairing && code.length == INVITE_LENGTH && ServerAddress.isWellFormed(serverUrl) &&
(!nameRequired || (name.isNotBlank() && !nameTooLong))
companion object {
const val NAME_MAX = 40
}
}
/**
@@ -188,7 +186,7 @@ class PairingViewModel internal constructor(
internal fun pairingError(code: String): PairingError = when (code) {
"invite_invalid" -> PairingError.InviteInvalid
"rate_limited" -> PairingError.RateLimited
"network" -> PairingError.Network
FAILURE_NETWORK -> PairingError.Network
"protocol_unsupported" -> PairingError.AppTooOld
"internal", "unavailable", "not_implemented" -> PairingError.Server
else -> PairingError.Validation
@@ -46,7 +46,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AvatarShape
import se.hagstedt.kvitt.design.component.AvatarState
@@ -61,11 +60,13 @@ import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.format.DISPLAY_NAME_MAX
import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.User
import java.io.IOException
import se.hagstedt.kvitt.core.design.R as DesignR
/** What S-2 can be asked to do; the stateless [ProfileStepContent] calls these. */
data class ProfileStepActions(
@@ -256,7 +257,7 @@ private fun ProfileCard(state: ProfileStepUiState, actions: ProfileStepActions)
label = stringResource(R.string.onboarding_name_label),
error = when {
state.nameBlank -> stringResource(R.string.onboarding_error_name_required)
state.nameTooLong -> pluralStringResource(DesignR.plurals.error_too_long, PairingUiState.NAME_MAX, PairingUiState.NAME_MAX)
state.nameTooLong -> pluralStringResource(DesignR.plurals.error_too_long, DISPLAY_NAME_MAX, DISPLAY_NAME_MAX)
else -> null
},
supporting = if (state.online) null else stringResource(DesignR.string.common_needs_connection),
@@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.format.DISPLAY_NAME_MAX
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository
@@ -40,7 +42,7 @@ data class ProfileStepUiState(
val done: Boolean = false,
) {
val nameBlank: Boolean get() = name.isBlank()
val nameTooLong: Boolean get() = name.trim().let { it.codePointCount(0, it.length) } > PairingUiState.NAME_MAX
val nameTooLong: Boolean get() = name.trim().let { it.codePointCount(0, it.length) } > DISPLAY_NAME_MAX
val hasPicture: Boolean get() = me?.pictureVersion != null
/** "Fortsätt": disabled while the picture uploads and while the name is blank or too long. */
@@ -126,7 +128,7 @@ class ProfileStepViewModel @Inject constructor(
OnlineResult.Offline -> it.copy(saving = false, message = ProfileStepMessage.CouldNotReachServer)
is OnlineResult.Failed -> it.copy(
saving = false,
message = if (result.code == "network") ProfileStepMessage.CouldNotReachServer else ProfileStepMessage.NameRefused,
message = if (result.code == FAILURE_NETWORK) ProfileStepMessage.CouldNotReachServer else ProfileStepMessage.NameRefused,
)
}
}
@@ -146,7 +148,7 @@ internal fun pictureMessage(result: OnlineResult<Unit>): ProfileStepMessage? = w
"payload_too_large" -> ProfileStepMessage.PictureTooLarge
"unsupported_media_type" -> ProfileStepMessage.PictureUnsupported
"image_invalid" -> ProfileStepMessage.PictureInvalid
"network" -> ProfileStepMessage.CouldNotReachServer
FAILURE_NETWORK -> ProfileStepMessage.CouldNotReachServer
else -> ProfileStepMessage.PictureFailed
}
}
@@ -25,7 +25,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.BalanceHero
import se.hagstedt.kvitt.design.component.ButtonSize
@@ -44,8 +43,10 @@ import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.design.icon.KvittIcon
import kotlin.time.Instant
import se.hagstedt.kvitt.core.design.R as DesignR
/** What S-3 can be asked to do; the stateless [WaitingContent] calls these. */
data class WaitingActions(
@@ -83,7 +84,7 @@ fun WaitingContent(
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
val error = state.loadError?.let {
if (it == "network") DesignR.string.common_could_not_reach_server else DesignR.string.error_server
if (it == FAILURE_NETWORK) DesignR.string.common_could_not_reach_server else DesignR.string.error_server
}
val resources = LocalResources.current
LaunchedEffect(error) {