Android overhaul: the §9.6 text and date limits in :core:design's Limits.kt

DESCRIPTION_MAX (S-7 and S-19 each had 100), NOTES_MAX (S-7's 1000 and NotesField's private copy), FIRST_DATE and
DAYS_AHEAD_MAX (the S-7 and S-9 date pickers each spelled 2000-01-01 and 365). Values 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:09:39 +02:00
parent e08aaf099b
commit 0714242954
6 changed files with 34 additions and 21 deletions
@@ -22,9 +22,9 @@ import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -52,10 +52,10 @@ import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -65,6 +65,7 @@ import se.hagstedt.kvitt.design.format.AmountKey
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.format.MoneyFormatter import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.format.NOTES_MAX
import se.hagstedt.kvitt.design.icon.CheckBadgeIcon import se.hagstedt.kvitt.design.icon.CheckBadgeIcon
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
@@ -255,7 +256,7 @@ fun CategoryQuickRow(categories: List<Category>, selected: CategoryId?, onPick:
/** /**
* One category item of the quick row or the grid sheet. The check badge of a selected item overhangs the tile's top-right * One category item of the quick row or the grid sheet. The check badge of a selected item overhangs the tile's top-right
* corner; it is drawn in a layer above the clipped, clickable item so the item's ripple clip never cuts it (M8b). * corner; it is drawn in a layer above the clipped, clickable item so the item's ripple clip never cuts it.
*/ */
@Composable @Composable
fun CategoryItem(category: Category, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { fun CategoryItem(category: Category, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
@@ -636,7 +637,6 @@ fun NotesField(value: String, onValueChange: (String) -> Unit, expanded: Boolean
) )
} }
private const val NOTES_MAX = 1000
/** /**
* The compact row pinned under the app bar when the amount has scrolled away (design-spec §3.7): tile 36, * The compact row pinned under the app bar when the amount has scrolled away (design-spec §3.7): tile 36,
@@ -1,7 +1,21 @@
package se.hagstedt.kvitt.design.format package se.hagstedt.kvitt.design.format
import kotlinx.datetime.LocalDate
/** Basis points of a whole: a ratio's per-user shares sum to 10000 bp, 100 % (SPEC §1, §9.3). */ /** 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 const val BP_TOTAL = 10_000
/** A user's display name is 1 to 40 code points after trimming (SPEC §9.6). */ /** A user's display name is 1 to 40 code points after trimming (SPEC §9.6). */
const val DISPLAY_NAME_MAX = 40 const val DISPLAY_NAME_MAX = 40
/** An expense's or recurring template's description is 1 to 100 code points (SPEC §9.6). */
const val DESCRIPTION_MAX = 100
/** An expense's notes are 0 to 1000 code points (SPEC §9.6). */
const val NOTES_MAX = 1000
/** The earliest expense or settlement date (SPEC §9.6). */
val FIRST_DATE: LocalDate = LocalDate(2000, 1, 1)
/** The latest expense or settlement date is today plus this many days (SPEC §9.6). */
const val DAYS_AHEAD_MAX = 365
@@ -55,7 +55,6 @@ import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus import kotlinx.datetime.plus
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.AccountPicker import se.hagstedt.kvitt.design.component.AccountPicker
import se.hagstedt.kvitt.design.component.AmountDisplay import se.hagstedt.kvitt.design.component.AmountDisplay
import se.hagstedt.kvitt.design.component.AmountKeypad import se.hagstedt.kvitt.design.component.AmountKeypad
@@ -90,6 +89,10 @@ import se.hagstedt.kvitt.design.component.splitOption
import se.hagstedt.kvitt.design.component.viewerPercentOf import se.hagstedt.kvitt.design.component.viewerPercentOf
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.DAYS_AHEAD_MAX
import se.hagstedt.kvitt.design.format.DESCRIPTION_MAX
import se.hagstedt.kvitt.design.format.FIRST_DATE
import se.hagstedt.kvitt.design.format.NOTES_MAX
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
import se.hagstedt.kvitt.model.CategoryId import se.hagstedt.kvitt.model.CategoryId
@@ -97,6 +100,7 @@ import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.FieldError import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.SplitMode import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.UserId import se.hagstedt.kvitt.model.UserId
import se.hagstedt.kvitt.core.design.R as DesignR
/** What S-7 can ask its state holder to do; [ExpenseEditorViewModel] implements it. */ /** What S-7 can ask its state holder to do; [ExpenseEditorViewModel] implements it. */
interface ExpenseEditorActions { interface ExpenseEditorActions {
@@ -400,8 +404,8 @@ private fun EditorDatePicker(date: String, today: String, actions: ExpenseEditor
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun LocalizedDatePicker(date: String, today: String, actions: ExpenseEditorActions, inline: Boolean) { private fun LocalizedDatePicker(date: String, today: String, actions: ExpenseEditorActions, inline: Boolean) {
val first = remember { LocalDate(FIRST_YEAR, 1, 1).toEpochDays() * DAY_MILLIS } val first = remember { FIRST_DATE.toEpochDays() * DAY_MILLIS }
val last = remember(today) { LocalDate.parse(today).plus(MAX_DAYS_AHEAD, DateTimeUnit.DAY).toEpochDays() * DAY_MILLIS } val last = remember(today) { LocalDate.parse(today).plus(DAYS_AHEAD_MAX, DateTimeUnit.DAY).toEpochDays() * DAY_MILLIS }
val picker = rememberDatePickerState( val picker = rememberDatePickerState(
initialSelectedDateMillis = LocalDate.parse(date).toEpochDays() * DAY_MILLIS, initialSelectedDateMillis = LocalDate.parse(date).toEpochDays() * DAY_MILLIS,
selectableDates = object : SelectableDates { selectableDates = object : SelectableDates {
@@ -479,9 +483,9 @@ internal fun errorText(errors: List<FieldError>, field: String): String? {
} }
ExpenseEditorViewModel.FIELD_DESCRIPTION -> when (error.error) { ExpenseEditorViewModel.FIELD_DESCRIPTION -> when (error.error) {
ExpenseEditorViewModel.REQUIRED -> stringResource(DesignR.string.error_description_required) ExpenseEditorViewModel.REQUIRED -> stringResource(DesignR.string.error_description_required)
else -> pluralStringResource(DesignR.plurals.error_too_long, MAX_DESCRIPTION, MAX_DESCRIPTION) else -> pluralStringResource(DesignR.plurals.error_too_long, DESCRIPTION_MAX, DESCRIPTION_MAX)
} }
ExpenseEditorViewModel.FIELD_NOTES -> pluralStringResource(DesignR.plurals.error_too_long, MAX_NOTES, MAX_NOTES) ExpenseEditorViewModel.FIELD_NOTES -> pluralStringResource(DesignR.plurals.error_too_long, NOTES_MAX, NOTES_MAX)
ExpenseEditorViewModel.FIELD_DATE -> stringResource(DesignR.string.error_date_out_of_range) ExpenseEditorViewModel.FIELD_DATE -> stringResource(DesignR.string.error_date_out_of_range)
ExpenseEditorViewModel.FIELD_SPLIT -> when (error.error) { ExpenseEditorViewModel.FIELD_SPLIT -> when (error.error) {
"exact_sum_mismatch", "exact_negative" -> stringResource(DesignR.string.error_exact_share_too_large) "exact_sum_mismatch", "exact_negative" -> stringResource(DesignR.string.error_exact_share_too_large)
@@ -495,8 +499,4 @@ internal fun errorText(errors: List<FieldError>, field: String): String? {
private const val PERCENT_HUNDREDTHS = 100L private const val PERCENT_HUNDREDTHS = 100L
private const val HALF = 50 private const val HALF = 50
private const val MIN_AMOUNT_ORE = 1L private const val MIN_AMOUNT_ORE = 1L
private const val MAX_DESCRIPTION = 100
private const val MAX_NOTES = 1000
private const val FIRST_YEAR = 2000
private const val MAX_DAYS_AHEAD = 365
private const val DAY_MILLIS = 86_400_000L private const val DAY_MILLIS = 86_400_000L
@@ -77,6 +77,7 @@ 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.BP_TOTAL
import se.hagstedt.kvitt.design.format.DESCRIPTION_MAX
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
@@ -4,6 +4,7 @@ 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.design.format.BP_TOTAL
import se.hagstedt.kvitt.design.format.DESCRIPTION_MAX
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
@@ -16,9 +17,6 @@ import se.hagstedt.kvitt.model.UserId
/** Which keypad field has focus (design-spec §3.7: the keypad drives the amount, the exact share and the percents). */ /** Which keypad field has focus (design-spec §3.7: the keypad drives the amount, the exact share and the percents). */
enum class EditorFocus { None, Amount, RatioViewer, RatioPartner, Exact } 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
/** /**
* 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
* the schedule of FR-9. Pure, so the editor's rules are unit-tested without Android. * the schedule of FR-9. Pure, so the editor's rules are unit-tested without Android.
@@ -40,7 +40,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus import kotlinx.datetime.plus
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.AccountTile import se.hagstedt.kvitt.design.component.AccountTile
import se.hagstedt.kvitt.design.component.AllocationRow import se.hagstedt.kvitt.design.component.AllocationRow
@@ -69,20 +68,21 @@ import se.hagstedt.kvitt.design.component.TopBarNavigation
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.money import se.hagstedt.kvitt.design.component.money
import se.hagstedt.kvitt.design.format.DAYS_AHEAD_MAX
import se.hagstedt.kvitt.design.format.DateFormatter import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.format.FIRST_DATE
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
import se.hagstedt.kvitt.model.Account import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.FieldError import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.User import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.core.design.R as DesignR
/** §9.6: the longest settlement note, in code points. */ /** §9.6: the longest settlement note, in code points. */
private const val NOTE_MAX = 200 private const val NOTE_MAX = 200
/** §9.6: dates from 2000-01-01 to today + 365 days. */ /** The date picker counts in UTC milliseconds; dates run from [FIRST_DATE] to today + [DAYS_AHEAD_MAX] days (§9.6). */
private val FIRST_DATE = LocalDate(2000, 1, 1)
private const val DAYS_AHEAD = 365
private const val MILLIS_PER_DAY = 86_400_000L private const val MILLIS_PER_DAY = 86_400_000L
/** /**
@@ -440,7 +440,7 @@ private fun AddAccountRow(account: Account, colorIndex: Int, onClick: () -> Unit
@Composable @Composable
private fun SettleDatePicker(isoDate: String, actions: SettleActions) { private fun SettleDatePicker(isoDate: String, actions: SettleActions) {
val today = LocalKvittClock.current.today val today = LocalKvittClock.current.today
val last = today.plus(DAYS_AHEAD, DateTimeUnit.DAY) val last = today.plus(DAYS_AHEAD_MAX, DateTimeUnit.DAY)
val selectable = remember(today) { val selectable = remember(today) {
object : SelectableDates { object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean { override fun isSelectableDate(utcTimeMillis: Long): Boolean {