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 df7cc39bf..1ec7f5582 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -30,6 +30,7 @@ import org.hisp.dhis.common.screens.FormShellsScreen import org.hisp.dhis.common.screens.FormsComponentsScreen import org.hisp.dhis.common.screens.IconButtonScreen import org.hisp.dhis.common.screens.InputIntegerScreen +import org.hisp.dhis.common.screens.InputLetterScreen import org.hisp.dhis.common.screens.InputLongTextScreen import org.hisp.dhis.common.screens.InputNegativeIntegerScreen import org.hisp.dhis.common.screens.InputNumberScreen @@ -56,7 +57,7 @@ fun App() { @Composable fun Main() { - val currentScreen = remember { mutableStateOf(Components.INPUT_LONG_TEXT) } + val currentScreen = remember { mutableStateOf(Components.INPUT_LETTER) } var expanded by remember { mutableStateOf(false) } Column( @@ -125,6 +126,7 @@ fun Main() { Components.INPUT_NEGATIVE_INTEGER -> InputNegativeIntegerScreen() Components.INPUT_INTEGER -> InputIntegerScreen() Components.INPUT_NUMBER -> InputNumberScreen() + Components.INPUT_LETTER -> InputLetterScreen() } } } 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 8784666d8..b85f30fc4 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 @@ -18,6 +18,7 @@ enum class Components(val label: String) { INPUT_POSITIVE_INTEGER("Input Positive Integer"), INPUT_INTEGER("Input Integer"), INPUT_NUMBER("Input Number"), + INPUT_LETTER("Input Letter"), FORM_SHELLS("Form Shells"), BOTTOM_SHEET("Bottom Sheet"), TAGS("Tags"), diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputLetterScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputLetterScreen.kt new file mode 100644 index 000000000..edfd7e017 --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputLetterScreen.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.InputLetter +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 InputLetterScreen() { + ColumnComponentContainer { + Title("Input Letter component", textColor = TextColor.OnSurfaceVariant) + SubTitle(" Basic Input Letter", textColor = TextColor.OnSurfaceVariant) + var inputValue1 by rememberSaveable { mutableStateOf("") } + + InputLetter( + title = "Label", + inputText = inputValue1, + onValueChanged = { + if (it != null) { + inputValue1 = it + } + }, + ) + SubTitle(" Basic Input Letter with erro", textColor = TextColor.OnSurfaceVariant) + var inputValueError by rememberSaveable { mutableStateOf("") } + + InputLetter( + title = "Label", + inputText = inputValueError, + onValueChanged = { + if (it != null) { + inputValueError = it + } + }, + supportingText = listOf(SupportingTextData("Letters only. eg. A, B, C", SupportingTextState.ERROR)), + state = InputShellState.ERROR, + ) + var inputValue2 by rememberSaveable { mutableStateOf("") } + SubTitle("Input Letter with legend", textColor = TextColor.OnSurfaceVariant) + InputLetter( + title = "Label", + inputText = inputValue2, + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + onValueChanged = { + if (it != null) { + inputValue2 = it + } + }, + ) + + var inputValue3 by rememberSaveable { mutableStateOf("") } + + SubTitle("Input Letter with Supporting text", textColor = TextColor.OnSurfaceVariant) + InputLetter( + title = "Label", + inputText = inputValue3, + supportingText = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + onValueChanged = { + if (it != null) { + inputValue3 = it + } + }, + ) + + var inputValue4 by rememberSaveable { mutableStateOf("") } + + SubTitle("Input Letter with Supporting text and legend", textColor = TextColor.OnSurfaceVariant) + + InputLetter( + title = "Label", + inputText = inputValue4, + supportingText = listOf( + SupportingTextData( + "Supporting text", + SupportingTextState.DEFAULT, + ), + ), + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + onValueChanged = { + if (it != null) { + inputValue4 = it + } + }, + ) + SubTitle("Input Letter with error and warning text and legend", textColor = TextColor.OnSurfaceVariant) + var inputValue5 by rememberSaveable { mutableStateOf("") } + + InputLetter( + 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 Letter ", textColor = TextColor.OnSurfaceVariant) + InputLetter( + title = "Label", + inputText = inputValue6, + state = InputShellState.DISABLED, + onValueChanged = { + if (it != null) { + inputValue6 = it + } + }, + ) + + var inputValue7 by rememberSaveable { mutableStateOf("A") } + + SubTitle("Disabled Input Letter with content ", textColor = TextColor.OnSurfaceVariant) + InputLetter( + 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/InputLetter.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputLetter.kt new file mode 100644 index 000000000..68bff5972 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputLetter.kt @@ -0,0 +1,112 @@ +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.KeyboardCapitalization +import java.util.Locale + +/** + * DHIS2 Input Letter. Wraps DHIS ยท [InputShell]. + * Component that only allows a single character, + * must be a single letter + * @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 modifier allows a modifier to be passed externally + */ +@Composable +fun InputLetter( + 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("^[A-Z]\$") } + val keyboardOptions = KeyboardOptions(imeAction = imeAction, capitalization = KeyboardCapitalization.Characters) + InputShell( + modifier = modifier, + isRequiredField = isRequiredField, + title = title, + primaryButton = { + if (deleteButtonIsVisible) { + IconButton( + modifier = Modifier.testTag("INPUT_LETTER_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_LETTER_LEGEND")) + } + }, + supportingText = { + supportingText?.forEach { + label -> + SupportingText( + label.text, + label.state, + modifier = Modifier.testTag("INPUT_LETTER_SUPPORTING_TEXT"), + ) + } + }, + inputField = { + BasicInput( + modifier = Modifier.testTag("INPUT_LETTER_FIELD"), + inputText = inputValue ?: "", + onInputChanged = { + if (it.uppercase(Locale.getDefault()).matches(pattern) || it.isEmpty()) { + onValueChanged?.invoke(it.uppercase(Locale.getDefault())) + } + 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/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/SupportingText.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/SupportingText.kt index 633d0d46c..22aa9f563 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/SupportingText.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/SupportingText.kt @@ -167,4 +167,4 @@ enum class SupportingTextState(val color: Color) { ERROR(SurfaceColor.Error), } -data class SupportingTextData(val text: String, val state: SupportingTextState) +data class SupportingTextData(val text: String, val state: SupportingTextState = SupportingTextState.DEFAULT) diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputLetterTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputLetterTest.kt new file mode 100644 index 000000000..a50edd4e8 --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputLetterTest.kt @@ -0,0 +1,165 @@ +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.Modifier +import androidx.compose.ui.platform.testTag +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 InputLetterTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun shouldDisplayInputTextCorrectly() { + rule.setContent { + InputLetter( + title = "Label", + modifier = Modifier.testTag("INPUT_LETTER"), + ) + } + rule.onNodeWithTag("INPUT_LETTER").assertExists() + rule.onNodeWithTag("INPUT_LETTER_LEGEND").assertDoesNotExist() + rule.onNodeWithTag("INPUT_LETTER_SUPPORTING_TEXT").assertDoesNotExist() + } + + @Test + fun shouldAllowUserInputWhenEnabled() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("") } + InputLetter( + title = "Label", + modifier = Modifier.testTag("INPUT_LETTER"), + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_LETTER").assertExists() + rule.onNodeWithTag("INPUT_LETTER_FIELD").performTextInput("A") + rule.onNodeWithTag("INPUT_LETTER_FIELD").assert(hasText("A")) + } + + @Test + fun shouldNotAllowUserInputWhenDisabled() { + rule.setContent { + InputLetter( + title = "Label", + modifier = Modifier.testTag("INPUT_LETTER"), + state = InputShellState.DISABLED, + ) + } + rule.onNodeWithTag("INPUT_LETTER").assertExists() + rule.onNodeWithTag("INPUT_LETTER_FIELD").assertIsNotEnabled() + } + + @Test + fun shouldShowResetButtonWhenTextFieldHasContent() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("") } + InputLetter( + title = "Label", + modifier = Modifier.testTag("INPUT_LETTER"), + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_LETTER").assertExists() + rule.onNodeWithTag("INPUT_LETTER_FIELD").assertExists() + rule.onNodeWithTag("INPUT_LETTER_FIELD").performTextInput("Input") + rule.onNodeWithTag("INPUT_LETTER_RESET_BUTTON").assertExists() + } + + @Test + fun shouldDeleteContentWhenResetButtonIsClickedAndHideResetButton() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("Input") } + + InputLetter( + title = "Label", + modifier = Modifier.testTag("INPUT_LETTER"), + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_LETTER").assertExists() + rule.onNodeWithTag("INPUT_LETTER_RESET_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_LETTER_RESET_BUTTON").performClick() + rule.onNodeWithTag("INPUT_LETTER_FIELD").assertTextEquals("") + rule.onNodeWithTag("INPUT_LETTER_RESET_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldShowLegendCorrectly() { + rule.setContent { + InputLetter( + title = "Label", + modifier = Modifier.testTag("INPUT_LETTER"), + inputText = "Input", + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + ) + } + rule.onNodeWithTag("INPUT_LETTER").assertExists() + rule.onNodeWithTag("INPUT_LETTER_LEGEND").assertExists() + rule.onNodeWithTag("INPUT_LETTER_LEGEND").assertHasClickAction() + } + + @Test + fun shouldShowSupportingTextCorrectly() { + rule.setContent { + InputLetter( + title = "Label", + modifier = Modifier.testTag("INPUT_LETTER"), + inputText = "Input", + supportingText = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + ) + } + rule.onNodeWithTag("INPUT_LETTER").assertExists() + rule.onNodeWithTag("INPUT_LETTER_SUPPORTING_TEXT").assertExists() + } + + @Test + fun shouldOnlyAllowASingleUpperCaseCharacter() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("") } + InputLetter( + title = "Label", + modifier = Modifier.testTag("INPUT_LETTER"), + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_LETTER").assertExists() + rule.onNodeWithTag("INPUT_LETTER_FIELD").performTextInput("a") + rule.onNodeWithTag("INPUT_LETTER_FIELD").assert(hasText("A")) + } +}