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 androidx.compose.ui.text.withStyle
import se.hagstedt.kvitt.core.design.R import se.hagstedt.kvitt.core.design.R
import se.hagstedt.kvitt.design.KvittTheme 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.format.MoneyFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens import se.hagstedt.kvitt.design.theme.KvittDimens
@@ -113,7 +114,7 @@ fun ratioSpoken(left: User, leftBp: Int, right: User): String {
personName(left), personName(left),
MoneyFormatter.basisPoints(leftBp, language), MoneyFormatter.basisPoints(leftBp, language),
personName(right), 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 = fun defaultModeLabel(viewerPercent: Int): String =
stringResource(R.string.editor_split_mode_default, stringResource(R.string.common_ratio, viewerPercent.toString(), (100 - viewerPercent).toString())) 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 * 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], * 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 HALF_BP = 5_000
private const val BP_PER_PERCENT = 100 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.kvittLanguage
import se.hagstedt.kvitt.design.component.ratioSpoken import se.hagstedt.kvitt.design.component.ratioSpoken
import se.hagstedt.kvitt.design.component.showKvittSnackbar 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.format.MoneyFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.model.AccountId 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) 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). */ /** `more_vert` with the S-5 menu; disabled items carry their reason as a second line (design-spec §3.11). */
@Composable @Composable
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.model.AccountDetailState import se.hagstedt.kvitt.model.AccountDetailState
import se.hagstedt.kvitt.model.AccountId import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository import se.hagstedt.kvitt.model.AccountRepository
@@ -170,10 +171,7 @@ class AccountDetailViewModel @Inject constructor(
} }
private fun failureMessage(code: String, action: AccountAction): AccountMessage = when (code) { private fun failureMessage(code: String, action: AccountAction): AccountMessage = when (code) {
NETWORK -> AccountMessage.CouldNotReach(action) FAILURE_NETWORK -> AccountMessage.CouldNotReach(action)
"account_not_empty" -> AccountMessage.DeleteBlocked "account_not_empty" -> AccountMessage.DeleteBlocked
else -> AccountMessage.Failed(code) 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.money
import se.hagstedt.kvitt.design.component.ratioSpoken import se.hagstedt.kvitt.design.component.ratioSpoken
import se.hagstedt.kvitt.design.component.showKvittSnackbar 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.format.MoneyFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens 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.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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.AccountId
import se.hagstedt.kvitt.model.AccountRepository import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.ConnectivityRepository 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 == "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 == "name" -> copy(saving = false, serverNameError = NameError.Invalid)
code == "validation_failed" && field == "default_shares" -> copy(saving = false, splitError = true) 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)) else -> copy(saving = false, message = EditorMessage.Failed(code))
} }
@@ -1,19 +1,12 @@
package se.hagstedt.kvitt.feature.account package se.hagstedt.kvitt.feature.account
import androidx.annotation.StringRes import androidx.annotation.StringRes
import se.hagstedt.kvitt.design.format.commonFailureText
import se.hagstedt.kvitt.core.design.R as DesignR 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_*`). */ /** The snackbar text of an `OnlineResult.Failed` code without a field at fault (§14.3.2, strings.csv `error_*`). */
@StringRes @StringRes
internal fun errorText(code: String): Int = when (code) { 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 "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.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.GraphicsMode
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.fakes.FakeScenario import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.account.AccountScenarios import se.hagstedt.kvitt.fakes.account.AccountScenarios
import se.hagstedt.kvitt.model.AccountId 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-delete-confirm", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() },
detail("S-5-account-empty", AccountScenarios.empty, AccountScenarios.sommarstugan.id), 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-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), detail("S-5-account-long", AccountScenarios.long, AccountScenarios.longAccount, largeFont = true),
editor("S-6-account-editor-create", AccountScenarios.editor), editor("S-6-account-editor-create", AccountScenarios.editor),
editor("S-6-account-editor", AccountScenarios.editor, largeFont = true) { it.onName("Sommarstugan"); it.onViewerPercent(60) }, 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-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-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-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") }, editor("S-6-account-editor-offline", AccountScenarios.editorOffline) { it.onName("Sommarstugan") },
saving(), saving(),
) )
@@ -5,6 +5,7 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.fakes.FakeRepositories import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenarios import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.account.AccountScenarios import se.hagstedt.kvitt.fakes.account.AccountScenarios
@@ -65,11 +66,11 @@ class AccountDetailViewModelTest {
@Test @Test
fun networkFailureOffersRetryAndChangesNothing() { 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) assertEquals(AccountMessage.CouldNotReach(AccountAction.Archive), ui.message)
assertFalse(ui.detail.account.archived) assertFalse(ui.detail.account.archived)
assertFalse(ui.busy) 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) assertNull(shown.message)
} }
@@ -190,7 +191,7 @@ class AccountEditorViewModelTest {
fun serverValidationMapsToTheField() { fun serverValidationMapsToTheField() {
assertEquals(NameError.Invalid, editorState(AccountScenarios.editor, failWith = "validation_failed", field = "name") { it.onName("X"); it.save() }.nameError) 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) 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) 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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme 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.AmountStyle
import se.hagstedt.kvitt.design.component.AmountTone import se.hagstedt.kvitt.design.component.AmountTone
import se.hagstedt.kvitt.design.component.BadgeKind 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.FactRow
import se.hagstedt.kvitt.design.component.KvittBadge import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.KvittIconImage 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.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LocalKvittClock import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.MoneyText 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.Timeline
import se.hagstedt.kvitt.design.component.TopBarNavigation import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.categoryName 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.detailDate
import se.hagstedt.kvitt.design.component.kvittLanguage import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.personName 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.shareText
import se.hagstedt.kvitt.design.component.spokenMoney 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.DateFormatter
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.AuditAction import se.hagstedt.kvitt.model.AuditAction
import se.hagstedt.kvitt.model.ExpenseDetailState import se.hagstedt.kvitt.model.ExpenseDetailState
import se.hagstedt.kvitt.model.ExpenseId import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.User import se.hagstedt.kvitt.model.User
import kotlin.time.Instant 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. */ /** What S-8 can ask its state holder to do; [ExpenseDetailViewModel] implements it. */
interface ExpenseDetailActions { interface ExpenseDetailActions {
@@ -181,7 +178,7 @@ private fun Loaded(detail: ExpenseDetailState, state: ExpenseDetailUiState) {
valueLeading = { PersonAvatar(detail.payer, KvittDimens.avatarChip) }, valueLeading = { PersonAvatar(detail.payer, KvittDimens.avatarChip) },
) )
2 -> { 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) val shares = shareText(detail.shares, me, partner, spoken = true)
FactRow( FactRow(
item, 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) 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 { private fun viewerPercent(detail: ExpenseDetailState, me: User): Float {
val total = detail.shares.sumOf { it.shareOre } val total = detail.shares.sumOf { it.shareOre }
val mine = detail.shares.firstOrNull { it.userId == me.id }?.shareOre ?: 0L 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 = 3
private const val FACTS_WITH_NOTES = 4 private const val FACTS_WITH_NOTES = 4
private const val BP_TOTAL = 10_000
private const val HALF_PERCENT = 50 private const val HALF_PERCENT = 50
private const val FULL_PERCENT = 100f 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.component.quickRowCategories
import se.hagstedt.kvitt.design.format.AmountInput import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey 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.Account
import se.hagstedt.kvitt.model.AccountId import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository import se.hagstedt.kvitt.model.AccountRepository
@@ -204,7 +205,7 @@ class ExpenseEditorViewModel @Inject constructor(
payer = draft.payer, payer = draft.payer,
mode = draft.mode, mode = draft.mode,
ratioViewer = viewerBp?.let { AmountInput.percent(it.toLong()) } ?: AmountInput(), 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, exactTyped = me,
exactInput = exactMine?.let(AmountInput::of) ?: AmountInput(), exactInput = exactMine?.let(AmountInput::of) ?: AmountInput(),
fullOn = draft.fullOn ?: if (draft.payer == me) partner else me, fullOn = draft.fullOn ?: if (draft.payer == me) partner else me,
@@ -523,7 +524,6 @@ class ExpenseEditorViewModel @Inject constructor(
/** design-spec §5.3. */ /** design-spec §5.3. */
const val SUGGESTION_DEBOUNCE_MS = 150L const val SUGGESTION_DEBOUNCE_MS = 150L
private const val BP_TOTAL = 10_000L
private const val HALF_BP = 5000 private const val HALF_BP = 5000
private const val PERCENT = 100 private const val PERCENT = 100
private val SPLIT_TARGETS = setOf(KeypadTarget.RatioViewer, KeypadTarget.RatioPartner, KeypadTarget.Exact) 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn 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.MaterialTheme
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.window.Dialog
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import se.hagstedt.kvitt.design.KvittElevation
import se.hagstedt.kvitt.design.KvittTheme import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.Banner import se.hagstedt.kvitt.design.component.Banner
import se.hagstedt.kvitt.design.component.BannerKind import se.hagstedt.kvitt.design.component.BannerKind
import se.hagstedt.kvitt.design.component.ButtonKind import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.ButtonSize import se.hagstedt.kvitt.design.component.ButtonSize
import se.hagstedt.kvitt.design.component.ExpenseRow 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.KvittButton
import se.hagstedt.kvitt.design.component.KvittCard import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.KvittIconImage 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.ProvideHousehold
import se.hagstedt.kvitt.design.component.SectionHeader import se.hagstedt.kvitt.design.component.SectionHeader
import se.hagstedt.kvitt.design.component.SegmentedGroup 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.SettlementRow
import se.hagstedt.kvitt.design.component.TopBarNavigation import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.kvittLanguage 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.relativeTime
import se.hagstedt.kvitt.design.component.savedSplitLabel
import se.hagstedt.kvitt.design.format.DateFormatter import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.EntityType import se.hagstedt.kvitt.model.EntityType
import se.hagstedt.kvitt.model.ExpenseId import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.LedgerRow import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.RejectedVersion
import se.hagstedt.kvitt.model.Rejection import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SettlementId import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.SyncRejectionReason 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 kotlin.time.Instant
import se.hagstedt.kvitt.core.design.R as DesignR 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) } add { item -> ExpenseRow(item, row, onClick = null) }
lost.accountName?.let { name -> add { item -> FactRow(item, KvittIcon.Inventory2, stringResource(DesignR.string.editor_account), name) } } 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.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) } if (lost.notes.isNotEmpty()) add { item -> FactRow(item, KvittIcon.Notes, stringResource(DesignR.string.editor_notes), lost.notes) }
} }
is LedgerRow.SettlementRow -> { 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) } 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.INVITE_LENGTH
import se.hagstedt.kvitt.design.component.normalizeInviteCode import se.hagstedt.kvitt.design.component.normalizeInviteCode
import se.hagstedt.kvitt.design.format.DISPLAY_NAME_MAX
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import java.net.URLDecoder 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. */ /** A display name of §9.6 (1 to 40 code points after trimming, no control characters), else null. */
private fun adminName(value: String?): String? { private fun adminName(value: String?): String? {
val name = value?.trim() ?: return null 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 FROM = "from"
private const val NAME_MAX = 40
/** The normalised code when [text] normalises to exactly 12 alphabet characters (§8.3), else null. */ /** The normalised code when [text] normalises to exactly 12 alphabet characters (§8.3), else null. */
private fun code(text: String): String? { 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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.design.KvittTheme import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.InviteCodeField import se.hagstedt.kvitt.design.component.InviteCodeField
import se.hagstedt.kvitt.design.component.KvittIconImage import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTextField import se.hagstedt.kvitt.design.component.KvittTextField
import se.hagstedt.kvitt.design.component.showKvittSnackbar 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.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens 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. */ /** What S-1 can be asked to do; the stateless [PairingContent] calls these. */
data class PairingActions( data class PairingActions(
@@ -172,7 +173,7 @@ fun PairingContent(
onValueChange = actions.onNameChange, onValueChange = actions.onNameChange,
label = stringResource(R.string.onboarding_name_label), label = stringResource(R.string.onboarding_name_label),
error = if (state.nameTooLong) { 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 { } else {
null null
}, },
@@ -10,8 +10,10 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.component.INVITE_LENGTH import se.hagstedt.kvitt.design.component.INVITE_LENGTH
import se.hagstedt.kvitt.design.component.normalizeInviteCode 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.InviteKind
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SessionRepository import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState import se.hagstedt.kvitt.model.SessionState
import javax.inject.Inject import javax.inject.Inject
@@ -63,16 +65,12 @@ data class PairingUiState(
val nameRequired: Boolean get() = mode == PairingMode.First val nameRequired: Boolean get() = mode == PairingMode.First
/** The display name is 1 to 40 code points after trimming (§9.6). */ /** 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. */ /** "Para ihop" is enabled when the code has 12 characters, the name is set (first pairing), the address is well formed. */
val canPair: Boolean val canPair: Boolean
get() = !pairing && code.length == INVITE_LENGTH && ServerAddress.isWellFormed(serverUrl) && get() = !pairing && code.length == INVITE_LENGTH && ServerAddress.isWellFormed(serverUrl) &&
(!nameRequired || (name.isNotBlank() && !nameTooLong)) (!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) { internal fun pairingError(code: String): PairingError = when (code) {
"invite_invalid" -> PairingError.InviteInvalid "invite_invalid" -> PairingError.InviteInvalid
"rate_limited" -> PairingError.RateLimited "rate_limited" -> PairingError.RateLimited
"network" -> PairingError.Network FAILURE_NETWORK -> PairingError.Network
"protocol_unsupported" -> PairingError.AppTooOld "protocol_unsupported" -> PairingError.AppTooOld
"internal", "unavailable", "not_implemented" -> PairingError.Server "internal", "unavailable", "not_implemented" -> PairingError.Server
else -> PairingError.Validation else -> PairingError.Validation
@@ -46,7 +46,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.design.KvittTheme import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AvatarShape import se.hagstedt.kvitt.design.component.AvatarShape
import se.hagstedt.kvitt.design.component.AvatarState 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.ProvideHousehold
import se.hagstedt.kvitt.design.component.kvittLanguage import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.showKvittSnackbar 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.format.KvittLanguage
import se.hagstedt.kvitt.design.icon.KvittIcon import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.User import se.hagstedt.kvitt.model.User
import java.io.IOException 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. */ /** What S-2 can be asked to do; the stateless [ProfileStepContent] calls these. */
data class ProfileStepActions( data class ProfileStepActions(
@@ -256,7 +257,7 @@ private fun ProfileCard(state: ProfileStepUiState, actions: ProfileStepActions)
label = stringResource(R.string.onboarding_name_label), label = stringResource(R.string.onboarding_name_label),
error = when { error = when {
state.nameBlank -> stringResource(R.string.onboarding_error_name_required) 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 else -> null
}, },
supporting = if (state.online) null else stringResource(DesignR.string.common_needs_connection), 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.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository import se.hagstedt.kvitt.model.ProfileRepository
@@ -40,7 +42,7 @@ data class ProfileStepUiState(
val done: Boolean = false, val done: Boolean = false,
) { ) {
val nameBlank: Boolean get() = name.isBlank() 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 val hasPicture: Boolean get() = me?.pictureVersion != null
/** "Fortsätt": disabled while the picture uploads and while the name is blank or too long. */ /** "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) OnlineResult.Offline -> it.copy(saving = false, message = ProfileStepMessage.CouldNotReachServer)
is OnlineResult.Failed -> it.copy( is OnlineResult.Failed -> it.copy(
saving = false, 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 "payload_too_large" -> ProfileStepMessage.PictureTooLarge
"unsupported_media_type" -> ProfileStepMessage.PictureUnsupported "unsupported_media_type" -> ProfileStepMessage.PictureUnsupported
"image_invalid" -> ProfileStepMessage.PictureInvalid "image_invalid" -> ProfileStepMessage.PictureInvalid
"network" -> ProfileStepMessage.CouldNotReachServer FAILURE_NETWORK -> ProfileStepMessage.CouldNotReachServer
else -> ProfileStepMessage.PictureFailed else -> ProfileStepMessage.PictureFailed
} }
} }
@@ -25,7 +25,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.KvittTheme
import se.hagstedt.kvitt.design.component.BalanceHero import se.hagstedt.kvitt.design.component.BalanceHero
import se.hagstedt.kvitt.design.component.ButtonSize 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.kvittLanguage
import se.hagstedt.kvitt.design.component.showKvittSnackbar import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.format.DateFormatter import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
import se.hagstedt.kvitt.design.icon.KvittIcon import se.hagstedt.kvitt.design.icon.KvittIcon
import kotlin.time.Instant 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. */ /** What S-3 can be asked to do; the stateless [WaitingContent] calls these. */
data class WaitingActions( data class WaitingActions(
@@ -83,7 +84,7 @@ fun WaitingContent(
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) { ) {
val error = state.loadError?.let { 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 val resources = LocalResources.current
LaunchedEffect(error) { LaunchedEffect(error) {
@@ -9,9 +9,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalResources
import se.hagstedt.kvitt.design.component.showKvittSnackbar 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.model.OnlineResult
import se.hagstedt.kvitt.core.design.R as DesignR 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 * 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 OFFLINE = "offline"
const val NETWORK = "network"
/** /**
* The words for a refused online call (design-spec §1, `strings.csv` `error_*`): network → "Kunde inte nå servern", * The words for a refused online call (design-spec §1, `strings.csv` `error_*`): offline → "Kräver anslutning", this
* offline → "Kräver anslutning", known §14.3.2 codes → their text, anything else → the generic validation text. * module's codes → their text, the shared ones through [commonFailureText] (network → "Kunde inte nå servern"),
* anything else → the generic validation text.
*/ */
@StringRes @StringRes
fun failureText(code: String): Int = when (code) { fun failureText(code: String): Int = when (code) {
NETWORK -> DesignR.string.common_could_not_reach_server
OFFLINE -> DesignR.string.common_needs_connection OFFLINE -> DesignR.string.common_needs_connection
"household_full" -> DesignR.string.error_household_full "household_full" -> DesignR.string.error_household_full
"household_incomplete" -> DesignR.string.error_household_incomplete "invalid_request" -> DesignR.string.error_validation
"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
"image_invalid" -> DesignR.string.profile_picture_invalid "image_invalid" -> DesignR.string.profile_picture_invalid
"payload_too_large" -> DesignR.string.profile_picture_too_large "payload_too_large" -> DesignR.string.profile_picture_too_large
"unsupported_media_type" -> DesignR.string.profile_picture_unsupported "unsupported_media_type" -> DesignR.string.profile_picture_unsupported
"category_in_use" -> R.string.settings_category_delete_blocked_in_use "category_in_use" -> R.string.settings_category_delete_blocked_in_use
"category_is_default" -> R.string.settings_category_delete_blocked_default "category_is_default" -> R.string.settings_category_delete_blocked_default
"cannot_revoke_current_device" -> R.string.settings_devices_cannot_revoke_current "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]. */ /** 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 * 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.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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.UiMessage
import se.hagstedt.kvitt.feature.settings.common.failureCode import se.hagstedt.kvitt.feature.settings.common.failureCode
import se.hagstedt.kvitt.feature.settings.common.failureMessage import se.hagstedt.kvitt.feature.settings.common.failureMessage
@@ -76,7 +76,7 @@ class DevicesViewModel @Inject constructor(
val list = when (result) { val list = when (result) {
is OnlineResult.Ok -> DeviceList.Loaded(result.value.filter { d -> !d.revoked }) is OnlineResult.Ok -> DeviceList.Loaded(result.value.filter { d -> !d.revoked })
OnlineResult.Offline -> DeviceList.Unavailable(unreachable = false) 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) } local.update { it.copy(list = list) }
} }
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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.UiMessage
import se.hagstedt.kvitt.feature.settings.common.failureCode import se.hagstedt.kvitt.feature.settings.common.failureCode
import se.hagstedt.kvitt.feature.settings.common.failureMessage 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 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. */ /** The local check of a display name, or null when it may be sent. */
fun nameError(name: String): NameError? { fun nameError(name: String): NameError? {
val trimmed = name.trim() val trimmed = name.trim()
val length = trimmed.codePointCount(0, trimmed.length) val length = trimmed.codePointCount(0, trimmed.length)
return when { return when {
length == 0 -> NameError.Required length == 0 -> NameError.Required
length > NAME_MAX -> NameError.TooLong(NAME_MAX) length > DISPLAY_NAME_MAX -> NameError.TooLong(DISPLAY_NAME_MAX)
else -> null 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.component.splitOption
import se.hagstedt.kvitt.design.format.AmountInput import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey 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.DateFormatter
import se.hagstedt.kvitt.design.format.KvittLanguage import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.design.icon.KvittIcon 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) }, accountOption(state.account?.name.orEmpty(), state.accountColorIndex) { actions.onOpenSheet(EditorSheet.Account) },
payerOption(payer, if (payer == me) partner else me, actions.onTogglePayer), payerOption(payer, if (payer == me) partner else me, actions.onTogglePayer),
splitOption( 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, form.splitOpen,
actions.onToggleSplit, actions.onToggleSplit,
), ),
@@ -252,7 +253,7 @@ private fun EditorBody(state: RecurringEditorState, actions: RecurringEditorActi
SplitMode.RATIO -> { SplitMode.RATIO -> {
val viewerTyped = form.ratioTyped == RatioField.Viewer val viewerTyped = form.ratioTyped == RatioField.Viewer
val viewerText = if (viewerTyped) form.ratioInput.display(language) else AmountInput.percent(form.viewerBp.toLong()).display(language) 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) { val focused = when (form.focus) {
EditorFocus.RatioViewer -> RatioField.Viewer EditorFocus.RatioViewer -> RatioField.Viewer
EditorFocus.RatioPartner -> RatioField.Partner 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.component.RatioField
import se.hagstedt.kvitt.design.format.AmountInput import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey 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.AccountId
import se.hagstedt.kvitt.model.CategoryId import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseDraft 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. */ /** §9.6: description 1 to 100, notes 0 to 1000 code points. */
const val DESCRIPTION_MAX = 100 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 * The S-19 editor's input, independent of the repositories: the expense fields of S-7 without the date (D-019) plus