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
@@ -48,6 +48,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import se.hagstedt.kvitt.core.design.R
import se.hagstedt.kvitt.design.KvittTheme
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
@@ -113,7 +114,7 @@ fun ratioSpoken(left: User, leftBp: Int, right: User): String {
personName(left),
MoneyFormatter.basisPoints(leftBp, language),
personName(right),
MoneyFormatter.basisPoints(10_000 - leftBp, language),
MoneyFormatter.basisPoints(BP_TOTAL - leftBp, language),
)
}
@@ -202,6 +203,29 @@ fun splitModeLabel(mode: SplitMode, defaultLabel: String): String = when (mode)
fun defaultModeLabel(viewerPercent: Int): String =
stringResource(R.string.editor_split_mode_default, stringResource(R.string.common_ratio, viewerPercent.toString(), (100 - viewerPercent).toString()))
/**
* A saved split as one line (design-spec S-8, S-16): "Kontots standard 50/50" or "Egen fördelning 70/30" with the
* [viewer]'s basis points from [shares] (50/50 for a default split without them), "Exakta belopp", or "Allt på dig" /
* "Allt på <[partnerName]>" by who bears the amount.
*/
@Composable
fun savedSplitLabel(mode: SplitMode, shares: List<Share>, viewer: UserId, partnerName: String): String {
val language = kvittLanguage()
val mineBp = shares.firstOrNull { it.userId == viewer }?.shareBp
val ratio = mineBp?.let { stringResource(R.string.common_ratio, MoneyFormatter.basisPoints(it, language), MoneyFormatter.basisPoints(BP_TOTAL - it, language)) }
return when (mode) {
SplitMode.DEFAULT -> if (ratio != null) stringResource(R.string.editor_split_mode_default, ratio) else defaultModeLabel(HALF_PERCENT)
SplitMode.RATIO -> splitModeLabel(mode, "") + (ratio?.let { " $it" } ?: "")
SplitMode.EXACT -> splitModeLabel(mode, "")
SplitMode.FULL -> {
val bearer = shares.firstOrNull { it.shareOre > 0 }?.userId
if (bearer == viewer) stringResource(R.string.editor_split_full_on_you) else stringResource(R.string.editor_split_full_on, partnerName)
}
}
}
private const val HALF_PERCENT = 50
/**
* The open split section (design-spec §3.7): a flat section with the header "Delning" and a collapse button, the four
* mode chips as a radio group, then [body]: the split bar and preview for `default`, or [RatioEditor],
@@ -474,4 +498,3 @@ fun viewerPercentOf(defaultShares: Map<UserId, Int>, viewer: UserId): Int = (def
private const val HALF_BP = 5_000
private const val BP_PER_PERCENT = 100
@@ -0,0 +1,25 @@
package se.hagstedt.kvitt.design.format
import androidx.annotation.StringRes
import se.hagstedt.kvitt.core.design.R
/** The code of an online call that did not reach the server: `OnlineResult.Failed("network", null)` (D-029). */
const val FAILURE_NETWORK = "network"
/**
* The shared snackbar text of a refused online call (design-spec §1, `strings.csv` `error_*`) for the codes every
* screen can meet: [FAILURE_NETWORK] is "Kunde inte nå servern", the §14.3.2 codes their text. Null for any other code,
* which the screen maps itself (falling back to `error_generic_invalid`).
*/
@StringRes
fun commonFailureText(code: String): Int? = when (code) {
FAILURE_NETWORK -> R.string.common_could_not_reach_server
"household_incomplete" -> R.string.error_household_incomplete
"not_found" -> R.string.error_not_found
"forbidden" -> R.string.error_forbidden
"rate_limited" -> R.string.error_rate_limited
"protocol_unsupported" -> R.string.error_app_too_old
"internal", "unavailable", "not_implemented" -> R.string.error_server
"validation_failed" -> R.string.error_validation
else -> null
}
@@ -0,0 +1,7 @@
package se.hagstedt.kvitt.design.format
/** Basis points of a whole: a ratio's per-user shares sum to 10000 bp, 100 % (SPEC §1, §9.3). */
const val BP_TOTAL = 10_000
/** A user's display name is 1 to 40 code points after trimming (SPEC §9.6). */
const val DISPLAY_NAME_MAX = 40
@@ -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)
}
@@ -30,8 +30,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.AmountStyle
import se.hagstedt.kvitt.design.component.AmountTone
import se.hagstedt.kvitt.design.component.BadgeKind
@@ -43,6 +41,7 @@ import se.hagstedt.kvitt.design.component.EmptyState
import se.hagstedt.kvitt.design.component.FactRow
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.MoneyText
@@ -55,23 +54,21 @@ import se.hagstedt.kvitt.design.component.SyncPendingMarker
import se.hagstedt.kvitt.design.component.Timeline
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.categoryName
import se.hagstedt.kvitt.design.component.defaultModeLabel
import se.hagstedt.kvitt.design.component.detailDate
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.personName
import se.hagstedt.kvitt.design.component.savedSplitLabel
import se.hagstedt.kvitt.design.component.shareText
import se.hagstedt.kvitt.design.component.spokenMoney
import se.hagstedt.kvitt.design.component.splitModeLabel
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.AuditAction
import se.hagstedt.kvitt.model.ExpenseDetailState
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.User
import kotlin.time.Instant
import se.hagstedt.kvitt.core.design.R as DesignR
/** What S-8 can ask its state holder to do; [ExpenseDetailViewModel] implements it. */
interface ExpenseDetailActions {
@@ -181,7 +178,7 @@ private fun Loaded(detail: ExpenseDetailState, state: ExpenseDetailUiState) {
valueLeading = { PersonAvatar(detail.payer, KvittDimens.avatarChip) },
)
2 -> {
val mode = modeLabel(detail, me, partner)
val mode = savedSplitLabel(detail.mode, detail.shares, me.id, partner.displayName)
val shares = shareText(detail.shares, me, partner, spoken = true)
FactRow(
item,
@@ -257,23 +254,6 @@ private fun DeletedBanner(detail: ExpenseDetailState) {
Banner(BannerKind.Neutral, KvittIcon.Delete, title, body = stringResource(R.string.expense_detail_deleted_body), iconTint = MaterialTheme.colorScheme.error)
}
/** "Kontots standard 50/50" / "Egen fördelning 70/30" / "Exakta belopp" / "Allt på Anna" (design-spec S-8). */
@Composable
private fun modeLabel(detail: ExpenseDetailState, me: User, partner: User): String {
val language = kvittLanguage()
val mineBp = detail.shares.firstOrNull { it.userId == me.id }?.shareBp
val ratio = mineBp?.let { stringResource(DesignR.string.common_ratio, MoneyFormatter.basisPoints(it, language), MoneyFormatter.basisPoints(BP_TOTAL - it, language)) }
return when (detail.mode) {
SplitMode.DEFAULT -> if (ratio != null) stringResource(DesignR.string.editor_split_mode_default, ratio) else defaultModeLabel(HALF_PERCENT)
SplitMode.RATIO -> splitModeLabel(SplitMode.RATIO, "") + (ratio?.let { " $it" } ?: "")
SplitMode.EXACT -> splitModeLabel(SplitMode.EXACT, "")
SplitMode.FULL -> {
val bearer = detail.shares.firstOrNull { it.shareOre > 0 }?.userId
if (bearer == me.id) stringResource(DesignR.string.editor_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, partner.displayName)
}
}
}
private fun viewerPercent(detail: ExpenseDetailState, me: User): Float {
val total = detail.shares.sumOf { it.shareOre }
val mine = detail.shares.firstOrNull { it.userId == me.id }?.shareOre ?: 0L
@@ -282,6 +262,5 @@ private fun viewerPercent(detail: ExpenseDetailState, me: User): Float {
private const val FACTS = 3
private const val FACTS_WITH_NOTES = 4
private const val BP_TOTAL = 10_000
private const val HALF_PERCENT = 50
private const val FULL_PERCENT = 100f
@@ -22,6 +22,7 @@ import se.hagstedt.kvitt.design.component.AccountChoice
import se.hagstedt.kvitt.design.component.quickRowCategories
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.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
@@ -204,7 +205,7 @@ class ExpenseEditorViewModel @Inject constructor(
payer = draft.payer,
mode = draft.mode,
ratioViewer = viewerBp?.let { AmountInput.percent(it.toLong()) } ?: AmountInput(),
ratioPartner = viewerBp?.let { AmountInput.percent(BP_TOTAL - it) } ?: AmountInput(),
ratioPartner = viewerBp?.let { AmountInput.percent((BP_TOTAL - it).toLong()) } ?: AmountInput(),
exactTyped = me,
exactInput = exactMine?.let(AmountInput::of) ?: AmountInput(),
fullOn = draft.fullOn ?: if (draft.payer == me) partner else me,
@@ -523,7 +524,6 @@ class ExpenseEditorViewModel @Inject constructor(
/** design-spec §5.3. */
const val SUGGESTION_DEBOUNCE_MS = 150L
private const val BP_TOTAL = 10_000L
private const val HALF_BP = 5000
private const val PERCENT = 100
private val SPLIT_TARGETS = setOf(KeypadTarget.RatioViewer, KeypadTarget.RatioPartner, KeypadTarget.Exact)
@@ -17,32 +17,41 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.window.Dialog
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.datetime.toLocalDateTime
import se.hagstedt.kvitt.design.KvittElevation
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.Banner
import se.hagstedt.kvitt.design.component.BannerKind
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.ButtonSize
import se.hagstedt.kvitt.design.component.ExpenseRow
import se.hagstedt.kvitt.design.component.FactRow
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.KvittIconImage
@@ -53,35 +62,23 @@ import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SectionHeader
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.SegmentedItemScope
import se.hagstedt.kvitt.design.component.SettlementRow
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.personName
import se.hagstedt.kvitt.design.component.relativeTime
import se.hagstedt.kvitt.design.component.savedSplitLabel
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.EntityType
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.RejectedVersion
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.SyncRejectionReason
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Surface
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.window.Dialog
import se.hagstedt.kvitt.design.component.FactRow
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.SegmentedItemScope
import se.hagstedt.kvitt.design.component.defaultModeLabel
import se.hagstedt.kvitt.design.component.personName
import se.hagstedt.kvitt.design.component.splitModeLabel
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.KvittElevation
import se.hagstedt.kvitt.model.RejectedVersion
import se.hagstedt.kvitt.model.SplitMode
import kotlin.time.Instant
import se.hagstedt.kvitt.core.design.R as DesignR
@@ -383,7 +380,7 @@ private fun LostFacts(lost: RejectedVersion, ui: SyncStatusUi.Ready, modifier: M
add { item -> ExpenseRow(item, row, onClick = null) }
lost.accountName?.let { name -> add { item -> FactRow(item, KvittIcon.Inventory2, stringResource(DesignR.string.editor_account), name) } }
lost.payer?.let { payer -> add { item -> FactRow(item, KvittIcon.Person, stringResource(DesignR.string.editor_paid_by), personName(payer)) } }
lost.mode?.let { mode -> add { item -> FactRow(item, KvittIcon.CallSplit, stringResource(DesignR.string.editor_split), lostModeLabel(mode, lost, ui)) } }
lost.mode?.let { mode -> add { item -> FactRow(item, KvittIcon.CallSplit, stringResource(DesignR.string.editor_split), savedSplitLabel(mode, lost.shares, ui.me.id, ui.partner?.displayName.orEmpty())) } }
if (lost.notes.isNotEmpty()) add { item -> FactRow(item, KvittIcon.Notes, stringResource(DesignR.string.editor_notes), lost.notes) }
}
is LedgerRow.SettlementRow -> {
@@ -394,23 +391,3 @@ private fun LostFacts(lost: RejectedVersion, ui: SyncStatusUi.Ready, modifier: M
}
SegmentedGroup(facts.size, modifier) { item -> facts[item.index](item) }
}
/** "Kontots standard 50/50" / "Egen fördelning 70/30" / "Exakta belopp" / "Allt på Anna", as on S-8. */
@Composable
private fun lostModeLabel(mode: SplitMode, lost: RejectedVersion, ui: SyncStatusUi.Ready): String {
val language = kvittLanguage()
val mineBp = lost.shares.firstOrNull { it.userId == ui.me.id }?.shareBp
val ratio = mineBp?.let { stringResource(DesignR.string.common_ratio, MoneyFormatter.basisPoints(it, language), MoneyFormatter.basisPoints(BP_TOTAL - it, language)) }
return when (mode) {
SplitMode.DEFAULT -> if (ratio != null) stringResource(DesignR.string.editor_split_mode_default, ratio) else splitModeLabel(mode, defaultModeLabel(HALF_PERCENT))
SplitMode.RATIO -> splitModeLabel(mode, "") + (ratio?.let { " $it" } ?: "")
SplitMode.EXACT -> splitModeLabel(mode, "")
SplitMode.FULL -> {
val bearer = lost.shares.firstOrNull { it.shareOre > 0 }?.userId
if (bearer == ui.me.id) stringResource(DesignR.string.editor_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, ui.partner?.displayName.orEmpty())
}
}
}
private const val BP_TOTAL = 10_000
private const val HALF_PERCENT = 50
@@ -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) {
@@ -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