diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Split.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Split.kt index d22cdb7..e284552 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Split.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Split.kt @@ -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, 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, viewer: UserId): Int = (def private const val HALF_BP = 5_000 private const val BP_PER_PERCENT = 100 - diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/Failures.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/Failures.kt new file mode 100644 index 0000000..66d359d --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/Failures.kt @@ -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 +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/Limits.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/Limits.kt new file mode 100644 index 0000000..3d03d20 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/Limits.kt @@ -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 diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt index 07f4098..26c3fe7 100644 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt @@ -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 diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt index 0135f9a..68d9aaa 100644 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailViewModel.kt @@ -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" diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt index cbba71d..11c3ac7 100644 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt @@ -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 diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorViewModel.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorViewModel.kt index a591360..1705ee0 100644 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorViewModel.kt +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorViewModel.kt @@ -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)) } diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/Errors.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/Errors.kt index bc24608..85294fa 100644 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/Errors.kt +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/Errors.kt @@ -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 } diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt index 6ef8e2f..8ab22a8 100644 --- a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt +++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt @@ -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 = 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 = 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(), ) diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt index eb004a7..cd5ad72 100644 --- a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt +++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt @@ -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) } diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt index 842b961..97b6a60 100644 --- a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt @@ -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 diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt index 3f0350d..d1a63f1 100644 --- a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt @@ -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) diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt index eca8ca9..1c42b6b 100644 --- a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt @@ -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 diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLink.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLink.kt index 3a1c9b4..71e6c4c 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLink.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLink.kt @@ -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? { diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt index 3e23694..16f027d 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt @@ -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 }, diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt index e034448..1fddac2 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt @@ -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 diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt index 7fb8ab4..680ae8a 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt @@ -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), diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepViewModel.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepViewModel.kt index 5873c80..89d2a8e 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepViewModel.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepViewModel.kt @@ -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): 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 } } diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt index 8357c41..cc54146 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt @@ -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) { diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Messages.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Messages.kt index bb73779..9115dd8 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Messages.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Messages.kt @@ -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 diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesViewModel.kt index 271b75b..e0ad8b8 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesViewModel.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesViewModel.kt @@ -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) } } diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileViewModel.kt index cfc2877..ad3c42a 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileViewModel.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileViewModel.kt @@ -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 } } diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt index 075548a..a4ed370 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt @@ -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 diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt index 7d34bf3..5a7b379 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt @@ -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