diff --git a/.claude/skills/use-forminput/SKILL.md b/.claude/skills/use-forminput/SKILL.md index e9c72e9..8876d0a 100644 --- a/.claude/skills/use-forminput/SKILL.md +++ b/.claude/skills/use-forminput/SKILL.md @@ -52,7 +52,7 @@ var name by remember { mutableStateOf(FormInputTextFieldState(id = "name", type FormInputTextField(state = name, onValueChange = { name = it.copy(hasError = it.value.isBlank(), error = "Required") }) var price by remember { mutableStateOf(FormInputPriceState(id = "price", currency = "TSH", currencies = listOf("TSH", "USD"), label = "Price")) } -FormInputPriceField(state = price, onValueChange = { price = it }, groupThousands = true) // price.amount is the plain number +FormInputPriceField(state = price, onValueChange = { price = it }, groupThousands = true) // price.amount is the plain number; a NUMBER FormInputTextField takes groupThousands too; groupingSeparator = ' ' changes the character var pw by remember { mutableStateOf(FormInputPasswordState(id = "pw", label = "Password")) } var confirm by remember { mutableStateOf(FormInputPasswordState(id = "pw2", label = "Confirm password")) } diff --git a/.claude/skills/use-forminput/reference.md b/.claude/skills/use-forminput/reference.md index 67d3708..2497967 100644 --- a/.claude/skills/use-forminput/reference.md +++ b/.claude/skills/use-forminput/reference.md @@ -26,8 +26,9 @@ parameter that is not listed here (`forminput/src/commonMain/kotlin/com/omarsheh - **`FormInputTextField(state, onValueChange: (State) -> Unit)`** and an overload `(state, value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit)`. Options: `textModifier`, `colors`, `shape`, `contentPadding`, `style`, `enabled`, `readOnly`, `textStyle`, `keyboardOptions`, `keyboardActions`, - `visualTransformation`, `leadingIcon`, `trailingIcon`. -- **`FormInputPriceField(state, onValueChange)`**: text-field options plus `currencyPlacement` (START/END), `groupThousands`, `currencyLabel: (String) -> String`, + `visualTransformation`, `leadingIcon`, `trailingIcon`, `groupThousands` (NUMBER only; shows 1234567 as 1,234,567, stored value stays plain), + `groupingSeparator` (default `,`; not a digit or a dot). +- **`FormInputPriceField(state, onValueChange)`**: text-field options plus `currencyPlacement` (START/END), `groupThousands`, `groupingSeparator`, `currencyLabel: (String) -> String`, `currencyContent`, `currencyTextStyle`, `menuShape`, `menuContainerColor`. - **`FormInputPasswordField(state, onValueChange)`**: text-field options plus `rules` (null = default four; `emptyList()` = none), `rulesVisibility` (ALWAYS, WHEN_FOCUSED, WHEN_NOT_EMPTY, NEVER), `rulesTitle`, `ruleContent`, `showStrength`, `strengthLabels`, `passwordColors`, `strengthOf`, diff --git a/app/src/main/java/com/omarshehe/forminputs/DemoSections.kt b/app/src/main/java/com/omarshehe/forminputs/DemoSections.kt index 1c1ffcc..8d1dea8 100644 --- a/app/src/main/java/com/omarshehe/forminputs/DemoSections.kt +++ b/app/src/main/java/com/omarshehe/forminputs/DemoSections.kt @@ -62,8 +62,14 @@ internal fun DemoSection(title: String, content: @Composable () -> Unit) { /** A text field of the demo: style and shape come from the surrounding `FormInputTheme`, the modifier from the look. */ @Composable -private fun DemoTextField(look: DemoLook, state: FormInputTextFieldState, enabled: Boolean = true, onChange: (FormInputTextFieldState) -> Unit = {}) { - FormInputTextField(state = state, onValueChange = onChange, modifier = look.fieldModifier, enabled = enabled) +private fun DemoTextField( + look: DemoLook, + state: FormInputTextFieldState, + enabled: Boolean = true, + groupThousands: Boolean = false, + onChange: (FormInputTextFieldState) -> Unit = {}, +) { + FormInputTextField(state = state, onValueChange = onChange, modifier = look.fieldModifier, enabled = enabled, groupThousands = groupThousands) } @Composable @@ -87,7 +93,7 @@ internal fun TextFieldsSection(look: DemoLook) = DemoSection("Text fields") { val required = tr("Required", "Obligatoire") DemoTextField(look, name.withLabel("Full name", "Nom complet")) { name = it.copy(hasError = it.value.isBlank(), error = required) } - DemoTextField(look, amount.withLabel("Amount (prefix)", "Montant (préfixe)")) { amount = it } + DemoTextField(look, amount.withLabel("Amount (prefix, grouped)", "Montant (préfixe, groupé)"), groupThousands = true) { amount = it } DemoTextField(look, weight.withLabel("Weight (suffix)", "Poids (suffixe)")) { weight = it } DemoTextField(look, phone.withLabel("Phone (digits and +)", "Téléphone (chiffres et +)")) { phone = it } DemoTextField(look, email.withLabel("Email", "E-mail")) { email = it } diff --git a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputPriceField.kt b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputPriceField.kt index 8b67cf5..b4b8ac1 100644 --- a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputPriceField.kt +++ b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputPriceField.kt @@ -56,7 +56,7 @@ public enum class CurrencyPlacement { START, END } * [style], [shape], [colors], [enabled] and the text options behave the same way. * * The amount keeps digits and one dot, at most [FormInputPriceState.maxIntegerDigits] whole digits and - * [FormInputPriceState.maxDecimals] decimals. [groupThousands] only changes how it is shown. With one currency the selector is + * [FormInputPriceState.maxDecimals] decimals. [groupThousands] only changes how it is shown, with [groupingSeparator] (not a digit or a dot) between the groups. With one currency the selector is * plain text. [currencyLabel] and [currencyContent] change how a currency is drawn, [menuShape] and [menuContainerColor] style * the list. */ @@ -76,6 +76,7 @@ public fun FormInputPriceField( keyboardActions: KeyboardActions = KeyboardActions.Default, currencyPlacement: CurrencyPlacement = CurrencyPlacement.START, groupThousands: Boolean = false, + groupingSeparator: Char = ',', currencyLabel: (String) -> String = { it }, currencyTextStyle: TextStyle? = null, menuShape: Shape? = null, @@ -144,6 +145,10 @@ public fun FormInputPriceField( } } + val groupingTransformation = remember(groupThousands, groupingSeparator) { + if (groupThousands) ThousandsSeparatorTransformation(groupingSeparator) else null + } + val fieldState = FormInputTextFieldState( id = state.id, labelRes = state.labelRes, @@ -183,7 +188,7 @@ public fun FormInputPriceField( keyboardType = if (state.maxDecimals > 0) KeyboardType.Decimal else KeyboardType.Number, ), keyboardActions = keyboardActions, - visualTransformation = if (groupThousands) ThousandsSeparatorTransformation() else null, + visualTransformation = groupingTransformation, leadingIcon = if (currencyPlacement == CurrencyPlacement.START) selector else null, trailingIcon = if (currencyPlacement == CurrencyPlacement.END) selector else null, ) diff --git a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputTextField.kt b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputTextField.kt index e94f135..0304740 100644 --- a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputTextField.kt +++ b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputTextField.kt @@ -65,6 +65,7 @@ import com.omarshehe.forminput.compose.ui.model.FormInputTextFieldState import com.omarshehe.forminput.compose.ui.model.FormInputType import com.omarshehe.forminput.compose.ui.utils.Dimens import com.omarshehe.forminput.compose.ui.utils.Symbols +import com.omarshehe.forminput.compose.ui.utils.ThousandsSeparatorTransformation /** * Text field driven by [FormInputTextFieldState.value]. The cursor and selection are kept internally; use the @@ -77,6 +78,8 @@ private val HeaderActionIconSize = 14.dp * The library's text input, driven by [FormInputTextFieldState]; [onValueChange] receives the updated state. [style], [shape] * and [colors] fall back to the form-wide defaults from [FormInputTheme]. [textStyle] and [contentPadding] style the text and * the box, [textModifier] reaches the text field itself, and [leadingIcon] and [trailingIcon] replace the built-in icons. + * For a NUMBER field, [groupThousands] shows the whole part in groups of three ("1234567" as "1,234,567") with [groupingSeparator] + * (not a digit or a dot) between them; the stored value stays plain. A [visualTransformation] you pass wins over it. */ @Composable public fun FormInputTextField( @@ -95,6 +98,8 @@ public fun FormInputTextField( visualTransformation: VisualTransformation? = null, leadingIcon: (@Composable () -> Unit)? = null, trailingIcon: (@Composable () -> Unit)? = null, + groupThousands: Boolean = false, + groupingSeparator: Char = ',', onValueChange: (FormInputTextFieldState) -> Unit = {}, ) { var textValueState by remember { mutableStateOf(TextFieldValue(text = state.value)) } @@ -124,6 +129,8 @@ public fun FormInputTextField( visualTransformation = visualTransformation, leadingIcon = leadingIcon, trailingIcon = trailingIcon, + groupThousands = groupThousands, + groupingSeparator = groupingSeparator, ) } @@ -131,6 +138,8 @@ public fun FormInputTextField( * Text field with a hoisted [TextFieldValue], so the caller owns the text, cursor and selection. Input is filtered * by the state's type, [FormInputTextFieldState.maxChar] and [FormInputTextFieldState.autoCapitalize] before * [onValueChange] is called; [FormInputTextFieldState.value] is not used by this overload. + * For a NUMBER field, [groupThousands] shows the whole part in groups of three with [groupingSeparator] (not a digit or a dot) between + * them; the [TextFieldValue] you receive and pass back stays plain, cursor included. A [visualTransformation] you pass wins over it. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -152,6 +161,8 @@ public fun FormInputTextField( visualTransformation: VisualTransformation? = null, leadingIcon: (@Composable () -> Unit)? = null, trailingIcon: (@Composable () -> Unit)? = null, + groupThousands: Boolean = false, + groupingSeparator: Char = ',', ) { val defaults = LocalFormInputDefaults.current val filled = (style ?: defaults.style) == FormInputFieldStyle.FILLED @@ -163,7 +174,10 @@ public fun FormInputTextField( var passwordVisible by remember { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } - val effectiveTransformation = visualTransformation ?: if (state.type == FormInputType.PASSWORD && !passwordVisible) { + val groupingTransformation = remember(groupThousands, groupingSeparator, state.type) { + if (groupThousands && state.type == FormInputType.NUMBER) ThousandsSeparatorTransformation(groupingSeparator) else null + } + val effectiveTransformation = visualTransformation ?: groupingTransformation ?: if (state.type == FormInputType.PASSWORD && !passwordVisible) { PasswordVisualTransformation() } else { VisualTransformation.None diff --git a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceFormat.kt b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceFormat.kt index a785a40..f956887 100644 --- a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceFormat.kt +++ b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceFormat.kt @@ -47,8 +47,15 @@ internal fun TextFieldValue.withCleanedText(cleaned: String): TextFieldValue { return copy(text = cleaned, selection = TextRange(moved(selection.start), moved(selection.end)), composition = null) } -/** Shows the whole part of an amount in groups of three ("1234567.5" as "1,234,567.5") without changing the stored text. */ +/** + * Shows the whole part of an amount in groups of three ("1234567.5" as "1,234,567.5") without changing the stored text. + * The [separator] may not be a digit or the decimal dot, which would make the shown text ambiguous. + */ internal class ThousandsSeparatorTransformation(private val separator: Char = ',') : VisualTransformation { + init { + require(!separator.isDigit() && separator != '.') { "The grouping separator may not be a digit or '.', but was '$separator'" } + } + override fun filter(text: AnnotatedString): TransformedText { val raw = text.text val dot = raw.indexOf('.').let { if (it < 0) raw.length else it } diff --git a/forminput/src/commonTest/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceAndPasswordHelpersTest.kt b/forminput/src/commonTest/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceAndPasswordHelpersTest.kt index 4315966..cb710d2 100644 --- a/forminput/src/commonTest/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceAndPasswordHelpersTest.kt +++ b/forminput/src/commonTest/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceAndPasswordHelpersTest.kt @@ -3,6 +3,7 @@ package com.omarshehe.forminput.compose.ui.utils import androidx.compose.ui.text.AnnotatedString import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull class PriceAndPasswordHelpersTest { @@ -53,6 +54,34 @@ class PriceAndPasswordHelpersTest { assertEquals(2, mapping.originalToTransformed(1)) } + @Test + fun customSeparatorIsUsed() { + assertEquals("1 234 567", ThousandsSeparatorTransformation(' ').filter(AnnotatedString("1234567")).text.text) + } + + @Test + fun separatorMayNotBeADotOrADigit() { + assertFailsWith { ThousandsSeparatorTransformation('.') } + assertFailsWith { ThousandsSeparatorTransformation('5') } + } + + @Test + fun emptyAndShortValuesGetNoSeparator() { + for (raw in listOf("", "1", "123", ".5", "0.5")) { + assertEquals(raw, ThousandsSeparatorTransformation().filter(AnnotatedString(raw)).text.text) + } + } + + @Test + fun cursorMappingRoundTripsWithCustomSeparator() { + val transformed = ThousandsSeparatorTransformation(' ').filter(AnnotatedString("1234567")) + val mapping = transformed.offsetMapping + assertEquals("1 234 567", transformed.text.text) + for (offset in 0..7) { + assertEquals(offset, mapping.transformedToOriginal(mapping.originalToTransformed(offset))) + } + } + private val rules = listOf( PasswordRules.upperCase("upper"), PasswordRules.special("special"), diff --git a/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/PriceAndPasswordFieldTest.kt b/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/PriceAndPasswordFieldTest.kt index a59215e..9926601 100644 --- a/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/PriceAndPasswordFieldTest.kt +++ b/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/PriceAndPasswordFieldTest.kt @@ -66,6 +66,12 @@ class PriceAndPasswordFieldTest { onNodeWithText("TSH!").assertExists() } + @Test + fun priceUsesTheCustomSeparator() = runComposeUiTest { + setContent { FormInputPriceField(state = price("1234567"), onValueChange = {}, groupThousands = true, groupingSeparator = ' ') } + onNodeWithText("1 234 567").assertExists() + } + @Test fun disabledPriceDoesNotOpenTheList() = runComposeUiTest { setContent { FormInputPriceField(state = price(), onValueChange = {}, enabled = false) } diff --git a/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/TextAndDropDownTest.kt b/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/TextAndDropDownTest.kt index 0aea404..4490cf9 100644 --- a/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/TextAndDropDownTest.kt +++ b/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/TextAndDropDownTest.kt @@ -65,6 +65,91 @@ class TextAndDropDownTest { assertEquals("12.34", state().value) } + private fun runGrouped(initial: FormInputTextFieldState, block: androidx.compose.ui.test.ComposeUiTest.(state: () -> FormInputTextFieldState) -> Unit) = + runComposeUiTest { + var state by mutableStateOf(initial) + setContent { FormInputTextField(state = state, onValueChange = { state = it }, groupThousands = true) } + block { state } + } + + @Test + fun groupedNumberShowsSeparatorsButStoresPlainDigits() = runGrouped(text(FormInputType.NUMBER, value = "1234567"), { + onNodeWithText("1,234,567").assertExists() + }) + + @Test + fun typingAGroupedNumberKeepsThePlainValue() = runGrouped(text(FormInputType.NUMBER), { state -> + onNodeWithText("Field").performTextInput("2000000") + assertEquals("2000000", state().value) + onNodeWithText("2,000,000").assertExists() + }) + + @Test + fun groupedDecimalsLeaveTheFractionAlone() = runGrouped(text(FormInputType.NUMBER, value = "1234567.891"), { + onNodeWithText("1,234,567.891").assertExists() + }) + + @Test + fun groupingIsIgnoredForOtherTypes() = runGrouped(text(FormInputType.PHONE, value = "1234567"), { + onNodeWithText("1234567").assertExists() + }) + + @Test + fun groupingIsOffByDefault() = runText(text(FormInputType.NUMBER, value = "1234567")) { + onNodeWithText("1234567").assertExists() + } + + @Test + fun customSeparatorOnANumberField() = runComposeUiTest { + setContent { + FormInputTextField(state = text(FormInputType.NUMBER, value = "1234567"), groupThousands = true, groupingSeparator = ' ') + } + onNodeWithText("1 234 567").assertExists() + } + + @Test + fun aCallerTransformationBeatsGrouping() = runComposeUiTest { + setContent { + FormInputTextField( + state = text(FormInputType.NUMBER, value = "1234567"), groupThousands = true, + visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None, + ) + } + onNodeWithText("1234567").assertExists() + } + + @Test + fun theTextFieldValueOverloadGroupsToo() = runComposeUiTest { + setContent { + FormInputTextField( + state = text(FormInputType.NUMBER), value = TextFieldValue("1234567"), onValueChange = {}, groupThousands = true, + ) + } + onNodeWithText("1,234,567").assertExists() + } + + @Test + fun typingIntoAGroupedFieldReportsAPlainValueWithTheCursorAfterTheLastDigit() = runComposeUiTest { + var value by mutableStateOf(TextFieldValue("")) + setContent { + FormInputTextField(state = text(FormInputType.NUMBER), value = value, onValueChange = { value = it }, groupThousands = true) + } + onNodeWithText("Field").performTextInput("2000000") + assertEquals("2000000", value.text) + assertEquals(TextRange(7), value.selection) + } + + @Test + fun anInsertInTheMiddleOfAGroupedNumberMovesTheCursorWithTheInsertedDigit() = runComposeUiTest { + var value by mutableStateOf(TextFieldValue("2000000", TextRange(4))) + setContent { + FormInputTextField(state = text(FormInputType.NUMBER), value = value, onValueChange = { value = it }, groupThousands = true) + } + onNodeWithText("2,000,000").performTextInput("9") + assertEquals("20009000", value.text) + assertEquals(TextRange(5), value.selection) + } + @Test fun phoneFieldKeepsDigitsAndPlus() = runText(text(FormInputType.PHONE)) { state -> onNodeWithText("Field").performTextInput("+255 7(12)")