diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt index 7e3936b78..a044ee8e4 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -31,6 +31,7 @@ import org.hisp.dhis.common.screens.FormsComponentsScreen import org.hisp.dhis.common.screens.IconButtonScreen import org.hisp.dhis.common.screens.InputNegativeIntegerScreen import org.hisp.dhis.common.screens.InputPercentageScreen +import org.hisp.dhis.common.screens.InputPositiveIntegerScreen import org.hisp.dhis.common.screens.InputScreen import org.hisp.dhis.common.screens.InputTextScreen import org.hisp.dhis.common.screens.LegendDescriptionScreen @@ -52,7 +53,7 @@ fun App() { @Composable fun Main() { - val currentScreen = remember { mutableStateOf(Components.INPUT_NEGATIVE_INTEGER) } + val currentScreen = remember { mutableStateOf(Components.INPUT_POSITIVE_INTEGER) } var expanded by remember { mutableStateOf(false) } Column( @@ -116,6 +117,7 @@ fun Main() { Components.BOTTOM_SHEET -> BottomSheetScreen() Components.TAGS -> TagsScreen() Components.SECTIONS -> SectionScreen() + Components.INPUT_POSITIVE_INTEGER -> InputPositiveIntegerScreen() Components.INPUT_PERCENTAGE -> InputPercentageScreen() Components.INPUT_NEGATIVE_INTEGER -> InputNegativeIntegerScreen() } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt index 3b457b070..fb9274db6 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt @@ -15,6 +15,7 @@ enum class Components(val label: String) { INPUT_TEXT("Input Text"), INPUT_NEGATIVE_INTEGER("Input Negative Integer"), INPUT_PERCENTAGE("Input Percentage"), + INPUT_POSITIVE_INTEGER("Input Positive Integer"), FORM_SHELLS("Form Shells"), BOTTOM_SHEET("Bottom Sheet"), TAGS("Tags"), diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputPositiveIntegerScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputPositiveIntegerScreen.kt new file mode 100644 index 000000000..2777b975a --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputPositiveIntegerScreen.kt @@ -0,0 +1,146 @@ +package org.hisp.dhis.common.screens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.ImeAction +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.InputPositiveInteger +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.LegendData +import org.hisp.dhis.mobile.ui.designsystem.component.SubTitle +import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData +import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextState +import org.hisp.dhis.mobile.ui.designsystem.component.Title +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +@Composable +fun InputPositiveIntegerScreen() { + ColumnComponentContainer { + Title("Input Integer component", textColor = TextColor.OnSurfaceVariant) + SubTitle("Basic Input Integer", textColor = TextColor.OnSurfaceVariant) + var inputValue1 by rememberSaveable { mutableStateOf("12") } + + InputPositiveInteger( + title = "Label", + inputText = inputValue1, + onValueChanged = { + if (it != null) { + inputValue1 = it + } + }, + ) + SubTitle("Basic Input Integer with error", textColor = TextColor.OnSurfaceVariant) + var inputValueError by rememberSaveable { mutableStateOf("") } + + InputPositiveInteger( + title = "Label", + inputText = inputValueError, + onValueChanged = { + if (it != null) { + inputValueError = it + } + }, + state = InputShellState.ERROR, + supportingText = listOf(SupportingTextData("Numbers only", SupportingTextState.ERROR)), + ) + var inputValue2 by rememberSaveable { mutableStateOf("") } + SubTitle("Input Integer with legend", textColor = TextColor.OnSurfaceVariant) + InputPositiveInteger( + title = "Label", + inputText = inputValue2, + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + onValueChanged = { + if (it != null) { + inputValue2 = it + } + }, + ) + + var inputValue3 by rememberSaveable { mutableStateOf("") } + + SubTitle("Input Integer with Supporting text", textColor = TextColor.OnSurfaceVariant) + InputPositiveInteger( + title = "Label", + inputText = inputValue3, + supportingText = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + onValueChanged = { + if (it != null) { + inputValue3 = it + } + }, + ) + + var inputValue4 by rememberSaveable { mutableStateOf("") } + + SubTitle("Input Integer with Supporting text and legend", textColor = TextColor.OnSurfaceVariant) + + InputPositiveInteger( + title = "Label", + inputText = inputValue4, + supportingText = listOf( + SupportingTextData( + "Supporting text", + SupportingTextState.DEFAULT, + ), + ), + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + onValueChanged = { + if (it != null) { + inputValue4 = it + } + }, + ) + SubTitle("Input Integer with error and warning text and legend", textColor = TextColor.OnSurfaceVariant) + var inputValue5 by rememberSaveable { mutableStateOf("") } + + InputPositiveInteger( + title = "Label", + inputText = inputValue5, + supportingText = listOf( + SupportingTextData("Supporting text", SupportingTextState.DEFAULT), + SupportingTextData("Supporting text", SupportingTextState.WARNING), + SupportingTextData("Supporting text", SupportingTextState.ERROR), + + ), + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + state = InputShellState.ERROR, + imeAction = ImeAction.Done, + onValueChanged = { + if (it != null) { + inputValue5 = it + } + }, + ) + var inputValue6 by rememberSaveable { mutableStateOf("") } + + SubTitle("Disabled Input Integer ", textColor = TextColor.OnSurfaceVariant) + InputPositiveInteger( + title = "Label", + inputText = inputValue6, + state = InputShellState.DISABLED, + onValueChanged = { + if (it != null) { + inputValue6 = it + } + }, + ) + + var inputValue7 by rememberSaveable { mutableStateOf("1234") } + + SubTitle("Disabled Input Integer with content ", textColor = TextColor.OnSurfaceVariant) + InputPositiveInteger( + title = "Label", + inputText = inputValue7, + state = InputShellState.DISABLED, + onValueChanged = { + if (it != null) { + inputValue7 = it + } + }, + ) + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPositiveInteger.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPositiveInteger.kt new file mode 100644 index 000000000..36c23260f --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPositiveInteger.kt @@ -0,0 +1,114 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType + +/** + * DHIS2 Input positive Integer. Wraps DHIS ยท [InputShell]. + * Only positive integers allowed, excluding 0 + * @param title controls the text to be shown for the title + * @param state Manages the InputShell state + * @param supportingText is a list of SupportingTextData that + * manages all the messages to be shown + * @param legendData manages the legendComponent + * @param inputText manages the value of the text in the input field + * @param isRequiredField controls whether the field is mandatory or not + * @param onNextClicked gives access to the imeAction event + * @param onValueChanged gives access to the onValueChanged event + * @param imeAction controls the imeAction button to be shown + * @param modifier allows a modifier to be passed externally + */ +@Composable +fun InputPositiveInteger( + title: String, + state: InputShellState = InputShellState.UNFOCUSED, + supportingText: List? = null, + legendData: LegendData? = null, + inputText: String? = null, + isRequiredField: Boolean = false, + onNextClicked: (() -> Unit)? = null, + onValueChanged: ((String?) -> Unit)? = null, + imeAction: ImeAction = ImeAction.Next, + modifier: Modifier = Modifier, +) { + val inputValue by remember(inputText) { mutableStateOf(inputText) } + + var deleteButtonIsVisible by remember { mutableStateOf(!inputText.isNullOrEmpty() && state != InputShellState.DISABLED) } + val focusManager = LocalFocusManager.current + val pattern = remember { Regex("^(?!0)\\d*") } + val keyboardOptions = KeyboardOptions(imeAction = imeAction, keyboardType = KeyboardType.Number) + InputShell( + modifier = modifier.testTag("INPUT_POSITIVE_INTEGER"), + isRequiredField = isRequiredField, + title = title, + primaryButton = { + if (deleteButtonIsVisible) { + IconButton( + modifier = Modifier.testTag("INPUT_POSITIVE_INTEGER_RESET_BUTTON"), + icon = { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = "Icon Button", + ) + }, + onClick = { + onValueChanged?.invoke("") + deleteButtonIsVisible = false + }, + enabled = state != InputShellState.DISABLED, + ) + } + }, + state = state, + legend = { + legendData?.let { + Legend(legendData, Modifier.testTag("INPUT_POSITIVE_INTEGER_LEGEND")) + } + }, + supportingText = { + supportingText?.forEach { + label -> + SupportingText( + label.text, + label.state, + modifier = Modifier.testTag("INPUT_POSITIVE_INTEGER_SUPPORTING_TEXT"), + ) + } + }, + inputField = { + BasicInput( + modifier = Modifier.testTag("INPUT_POSITIVE_INTEGER_FIELD"), + inputText = inputValue ?: "", + onInputChanged = { + if (it.matches(pattern) || it.isEmpty()) { + onValueChanged?.invoke(it) + deleteButtonIsVisible = it.isNotEmpty() + } + }, + enabled = state != InputShellState.DISABLED, + state = state, + keyboardOptions = keyboardOptions, + onNextClicked = { + if (onNextClicked != null) { + onNextClicked.invoke() + } else { + focusManager.moveFocus(FocusDirection.Down) + } + }, + ) + }, + ) +} diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPositiveIntegerTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPositiveIntegerTest.kt new file mode 100644 index 000000000..d329b4bc9 --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPositiveIntegerTest.kt @@ -0,0 +1,193 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.junit.Rule +import org.junit.Test + +class InputPositiveIntegerTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun shouldDisplayInputPositiveIntegerCorrectly() { + rule.setContent { + InputPositiveInteger( + title = "Label", + ) + } + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_LEGEND").assertDoesNotExist() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_SUPPORTING_TEXT").assertDoesNotExist() + } + + @Test + fun shouldAllowUserInputWhenEnabled() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("") } + InputPositiveInteger( + title = "Label", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_FIELD").performTextInput("1234") + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_FIELD").assert(hasText("1234")) + } + + @Test + fun shouldNotAllowUserInputWhenDisabled() { + rule.setContent { + InputPositiveInteger( + title = "Label", + state = InputShellState.DISABLED, + ) + } + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_FIELD").assertIsNotEnabled() + } + + @Test + fun shouldShowResetButtonWhenTextFieldHasContent() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("") } + InputPositiveInteger( + title = "Label", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_FIELD").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_FIELD").performTextInput("1234") + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_RESET_BUTTON").assertExists() + } + + @Test + fun shouldDeleteContentWhenResetButtonIsClickedAndHideResetButton() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("1234") } + + InputPositiveInteger( + title = "Label", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_RESET_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_RESET_BUTTON").performClick() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_FIELD").assertTextEquals("") + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_RESET_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldShowLegendCorrectly() { + rule.setContent { + InputPositiveInteger( + title = "Label", + inputText = "", + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + ) + } + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_LEGEND").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_LEGEND").assertHasClickAction() + } + + @Test + fun shouldShowSupportingTextCorrectly() { + rule.setContent { + InputPositiveInteger( + title = "Label", + inputText = "", + supportingText = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + ) + } + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_SUPPORTING_TEXT").assertExists() + } + + @Test + fun shouldNotAllowDecimalValues() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("") } + InputPositiveInteger( + title = "Label", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_FIELD").performTextInput("12.12") + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_FIELD").assert(hasText("")) + } + + @Test + fun shouldNotAllowNegativeValues() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("") } + InputPositiveInteger( + title = "Label", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_FIELD").performTextInput("-1212") + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_FIELD").assert(hasText("")) + } + + @Test + fun shouldNotAllowValuesWithALeadingZero() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("") } + InputPositiveInteger( + title = "Label", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER").assertExists() + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_FIELD").performTextInput("01212") + rule.onNodeWithTag("INPUT_POSITIVE_INTEGER_FIELD").assert(hasText("")) + } +}