Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/use-forminput/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")) }
Expand Down
5 changes: 3 additions & 2 deletions .claude/skills/use-forminput/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
12 changes: 9 additions & 3 deletions app/src/main/java/com/omarshehe/forminputs/DemoSections.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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)) }
Expand Down Expand Up @@ -124,13 +129,17 @@ public fun FormInputTextField(
visualTransformation = visualTransformation,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
groupThousands = groupThousands,
groupingSeparator = groupingSeparator,
)
}

/**
* 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
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<IllegalArgumentException> { ThousandsSeparatorTransformation('.') }
assertFailsWith<IllegalArgumentException> { 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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
Loading