From 9420dc6e8ed0e190a10d1f3a0c37e106091a3d20 Mon Sep 17 00:00:00 2001 From: Xavier Molloy <44061143+xavimolloy@users.noreply.github.com> Date: Fri, 8 Sep 2023 13:58:35 +0200 Subject: [PATCH] ANDROAPP-5531-mobile-ui-Create-Input-Percentage-component (#52) * Component implementation * Add tests for component * Add tests for component, adjust supporting text for padding values --- .../kotlin/org/hisp/dhis/common/App.kt | 4 +- .../hisp/dhis/common/screens/Components.kt | 1 + .../common/screens/InputPercentageScreen.kt | 146 +++++++++++++++ .../designsystem/component/InputPercentage.kt | 122 ++++++++++++ .../ui/designsystem/component/Sections.kt | 5 +- .../designsystem/component/SupportingText.kt | 8 +- .../component/InputPercentageTest.kt | 173 ++++++++++++++++++ 7 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputPercentageScreen.kt create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPercentage.kt create mode 100644 designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPercentageTest.kt 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 8d9237c77..546b90e5a 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -29,6 +29,7 @@ import org.hisp.dhis.common.screens.Components 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.InputPercentageScreen import org.hisp.dhis.common.screens.InputScreen import org.hisp.dhis.common.screens.InputTextScreen import org.hisp.dhis.common.screens.LegendDescriptionScreen @@ -50,7 +51,7 @@ fun App() { @Composable fun Main() { - val currentScreen = remember { mutableStateOf(Components.FORM_SHELLS) } + val currentScreen = remember { mutableStateOf(Components.INPUT_PERCENTAGE) } var expanded by remember { mutableStateOf(false) } Column( @@ -114,6 +115,7 @@ fun Main() { Components.BOTTOM_SHEET -> BottomSheetScreen() Components.TAGS -> TagsScreen() Components.SECTIONS -> SectionScreen() + Components.INPUT_PERCENTAGE -> InputPercentageScreen() } } } 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 f6e83ad65..43fa69757 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 @@ -13,6 +13,7 @@ enum class Components(val label: String) { LEGEND_DESCRIPTION("Legend description"), BUTTON_BLOCK("Button block"), INPUT_TEXT("Input Text"), + INPUT_PERCENTAGE("Input Percentage"), FORM_SHELLS("Form Shells"), BOTTOM_SHEET("Bottom Sheet"), TAGS("Tags"), diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputPercentageScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputPercentageScreen.kt new file mode 100644 index 000000000..37540dcff --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputPercentageScreen.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.InputPercentage +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 InputPercentageScreen() { + ColumnComponentContainer { + Title("Input Percentage component", textColor = TextColor.OnSurfaceVariant) + SubTitle("Basic Percentage ", textColor = TextColor.OnSurfaceVariant) + var inputValue1 by rememberSaveable { mutableStateOf("12") } + + InputPercentage( + title = "Label", + inputText = inputValue1, + onValueChanged = { + if (it != null) { + inputValue1 = it + } + }, + ) + SubTitle("Basic Percentage required field", textColor = TextColor.OnSurfaceVariant) + var inputValueRequired by rememberSaveable { mutableStateOf("") } + + InputPercentage( + title = "Label", + inputText = inputValueRequired, + onValueChanged = { + if (it != null) { + inputValueRequired = it + } + }, + state = InputShellState.ERROR, + isRequiredField = true, + ) + var inputValue2 by rememberSaveable { mutableStateOf("") } + SubTitle("Input Percentage with legend", textColor = TextColor.OnSurfaceVariant) + InputPercentage( + title = "Label", + inputText = inputValue2, + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + onValueChanged = { + if (it != null) { + inputValue2 = it + } + }, + ) + + var inputValue3 by rememberSaveable { mutableStateOf("") } + + SubTitle("Input Percentage with Supporting text", textColor = TextColor.OnSurfaceVariant) + InputPercentage( + title = "Label", + inputText = inputValue3, + supportingText = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + onValueChanged = { + if (it != null) { + inputValue3 = it + } + }, + ) + + var inputValue4 by rememberSaveable { mutableStateOf("") } + + SubTitle("Input Percentage with Supporting text and legend", textColor = TextColor.OnSurfaceVariant) + + InputPercentage( + title = "Label", + inputText = inputValue4, + supportingText = listOf( + SupportingTextData( + "Supporting text", + SupportingTextState.DEFAULT, + ), + ), + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + onValueChanged = { + if (it != null) { + inputValue4 = it + } + }, + ) + SubTitle("Input Percentage with error, warning text and legend", textColor = TextColor.OnSurfaceVariant) + var inputValue5 by rememberSaveable { mutableStateOf("") } + + InputPercentage( + 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 Percentage ", textColor = TextColor.OnSurfaceVariant) + InputPercentage( + title = "Label", + inputText = inputValue6, + state = InputShellState.DISABLED, + onValueChanged = { + if (it != null) { + inputValue6 = it + } + }, + ) + + var inputValue7 by rememberSaveable { mutableStateOf("1234") } + + SubTitle("Disabled Percentage with content ", textColor = TextColor.OnSurfaceVariant) + InputPercentage( + 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/InputPercentage.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPercentage.kt new file mode 100644 index 000000000..48540c176 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPercentage.kt @@ -0,0 +1,122 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.foundation.layout.padding +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 +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing + +/** + * DHIS2 Input percentage. Wraps DHIS ยท [InputShell]. + * Only integers allowed + * @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 InputPercentage( + 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("^([1-9]|[1-9][0-9]|100)\$") } + val keyboardOptions = KeyboardOptions(imeAction = imeAction, keyboardType = KeyboardType.Number) + InputShell( + modifier = modifier.testTag("INPUT_PERCENTAGE"), + isRequiredField = isRequiredField, + title = title, + primaryButton = { + if (deleteButtonIsVisible) { + IconButton( + modifier = Modifier.testTag("INPUT_PERCENTAGE_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_PERCENTAGE_LEGEND")) + } + }, + supportingText = { + supportingText?.forEach { + label -> + SupportingText( + label.text, + label.state, + modifier = Modifier.testTag("INPUT_PERCENTAGE_SUPPORTING_TEXT").padding( + start = Spacing.Spacing16, + top = Spacing.Spacing4, + end = Spacing.Spacing16, + ), + ) + } + }, + inputField = { + BasicInput( + modifier = Modifier.testTag("INPUT_PERCENTAGE_FIELD"), + helper = "%", + helperStyle = InputStyle.WITH_HELPER_AFTER, + 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/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Sections.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Sections.kt index b9c01ddfe..ae05bebe4 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Sections.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Sections.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -213,10 +214,10 @@ fun SectionHeader( ) description?.let { SupportingText( - modifier = Modifier.padding(Spacing.Spacing0) - .testTag(SectionTestTag.DESCRIPTION), + modifier = Modifier.testTag(SectionTestTag.DESCRIPTION), text = it, maxLines = 2, + paddingValues = PaddingValues(Spacing.Spacing0), onNoInteraction = { Pair(interactionSource, onSectionClick) }, 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 722cde520..633d0d46c 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 @@ -3,6 +3,7 @@ package org.hisp.dhis.mobile.ui.designsystem.component import androidx.compose.animation.animateContentSize import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.ClickableText import androidx.compose.material.ripple.LocalRippleTheme @@ -52,6 +53,11 @@ fun SupportingText( top = Spacing.Spacing4, end = Spacing.Spacing16, ), + paddingValues: PaddingValues = PaddingValues( + start = Spacing.Spacing16, + top = Spacing.Spacing4, + end = Spacing.Spacing16, + ), onNoInteraction: (() -> Pair Unit>)? = null, ) { var isExpanded by remember { mutableStateOf(false) } @@ -150,7 +156,7 @@ fun SupportingText( } } }, - modifier = modifier.animateContentSize(), + modifier = modifier.animateContentSize().padding(paddingValues), ) } } diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPercentageTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPercentageTest.kt new file mode 100644 index 000000000..e8598ec95 --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPercentageTest.kt @@ -0,0 +1,173 @@ +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 InputPercentageTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun shouldDisplayInputPositiveIntegerCorrectly() { + rule.setContent { + InputPercentage( + title = "Label", + ) + } + rule.onNodeWithTag("INPUT_PERCENTAGE").assertExists() + rule.onNodeWithTag("INPUT_PERCENTAGE_LEGEND").assertDoesNotExist() + rule.onNodeWithTag("INPUT_PERCENTAGE_SUPPORTING_TEXT").assertDoesNotExist() + } + + @Test + fun shouldAllowUserInputWhenEnabled() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("") } + InputPercentage( + title = "Label", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_PERCENTAGE").assertExists() + rule.onNodeWithTag("INPUT_PERCENTAGE_FIELD").performTextInput("25") + rule.onNodeWithTag("INPUT_PERCENTAGE_FIELD").assert(hasText("25 %")) + } + + @Test + fun shouldNotAllowUserInputWhenDisabled() { + rule.setContent { + InputPercentage( + title = "Label", + state = InputShellState.DISABLED, + ) + } + rule.onNodeWithTag("INPUT_PERCENTAGE").assertExists() + rule.onNodeWithTag("INPUT_PERCENTAGE_FIELD").assertIsNotEnabled() + } + + @Test + fun shouldShowResetButtonWhenTextFieldHasContent() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("") } + InputPercentage( + title = "Label", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_PERCENTAGE").assertExists() + rule.onNodeWithTag("INPUT_PERCENTAGE_FIELD").assertExists() + rule.onNodeWithTag("INPUT_PERCENTAGE_FIELD").performTextInput("25") + rule.onNodeWithTag("INPUT_PERCENTAGE_RESET_BUTTON").assertExists() + } + + @Test + fun shouldDeleteContentWhenResetButtonIsClickedAndHideResetButton() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("25") } + + InputPercentage( + title = "Label", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_PERCENTAGE").assertExists() + rule.onNodeWithTag("INPUT_PERCENTAGE_RESET_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_PERCENTAGE_RESET_BUTTON").performClick() + rule.onNodeWithTag("INPUT_PERCENTAGE_RESET_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldShowLegendCorrectly() { + rule.setContent { + InputPercentage( + title = "Label", + inputText = "", + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + ) + } + rule.onNodeWithTag("INPUT_PERCENTAGE").assertExists() + rule.onNodeWithTag("INPUT_PERCENTAGE_LEGEND").assertExists() + rule.onNodeWithTag("INPUT_PERCENTAGE_LEGEND").assertHasClickAction() + } + + @Test + fun shouldShowSupportingTextCorrectly() { + rule.setContent { + InputPercentage( + title = "Label", + inputText = "", + supportingText = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + ) + } + rule.onNodeWithTag("INPUT_PERCENTAGE").assertExists() + rule.onNodeWithTag("INPUT_PERCENTAGE_SUPPORTING_TEXT").assertExists() + } + + @Test + fun shouldNotAllowValuesOver100() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("") } + InputPercentage( + title = "Label", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_PERCENTAGE").assertExists() + rule.onNodeWithTag("INPUT_PERCENTAGE_FIELD").performTextInput("1212") + rule.onNodeWithTag("INPUT_PERCENTAGE_FIELD").assertTextEquals(" %") + } + + @Test + fun shouldNotAllowValuesWithALeadingZero() { + rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("") } + InputPercentage( + title = "Label", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + } + rule.onNodeWithTag("INPUT_PERCENTAGE").assertExists() + rule.onNodeWithTag("INPUT_PERCENTAGE_FIELD").performTextInput("012") + rule.onNodeWithTag("INPUT_PERCENTAGE_FIELD").assertTextEquals(" %") + } +}