diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/actionInputs/InputAgeScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/actionInputs/InputAgeScreen.kt index 733b5a0ba..6af361b3c 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/actionInputs/InputAgeScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/actionInputs/InputAgeScreen.kt @@ -11,10 +11,11 @@ import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer import org.hisp.dhis.mobile.ui.designsystem.component.ColumnScreenContainer import org.hisp.dhis.mobile.ui.designsystem.component.InputAge -import org.hisp.dhis.mobile.ui.designsystem.component.InputAgeModel 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.TimeUnitValues +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputAgeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputAgeState import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor @Composable @@ -24,100 +25,108 @@ fun InputAgeScreen() { ColumnComponentContainer("Input Age Component - Idle") { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = inputType, - onValueChanged = { newInputType -> - inputType = newInputType - }, ), + onValueChanged = { newInputType -> + inputType = newInputType ?: AgeInputType.None + }, ) } ColumnComponentContainer("Input Age Component - Idle Disabled") { InputAge( - InputAgeModel( - title = "Label", - inputType = AgeInputType.None, - state = InputShellState.DISABLED, - onValueChanged = { newInputType -> - inputType = newInputType - }, + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputState = InputShellState.DISABLED, ), + onValueChanged = { newInputType -> + inputType = newInputType ?: AgeInputType.None + }, ) } ColumnComponentContainer("Input Age Component - Date Of Birth") { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.DateOfBirth(TextFieldValue("01011985")), - state = InputShellState.DISABLED, - - onValueChanged = { newInputType -> - inputType = newInputType - }, + inputState = InputShellState.DISABLED, ), + onValueChanged = { newInputType -> + inputType = newInputType ?: AgeInputType.None + }, ) } ColumnComponentContainer("Input Age Component - Date Of Birth Required Error") { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + isRequired = true, + ), inputType = AgeInputType.DateOfBirth(TextFieldValue("010")), - state = InputShellState.ERROR, - isRequired = true, - - onValueChanged = { - // no-op - }, + inputState = InputShellState.ERROR, ), + onValueChanged = { + // no-op + }, ) } ColumnComponentContainer("Input Age Component - Age Disabled") { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.Age(value = TextFieldValue("56"), unit = TimeUnitValues.YEARS), - state = InputShellState.DISABLED, - - onValueChanged = { newInputType -> - inputType = newInputType - }, + inputState = InputShellState.DISABLED, ), + onValueChanged = { newInputType -> + inputType = newInputType ?: AgeInputType.None + }, ) } ColumnComponentContainer("Input Age Component - Age Required Error") { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + isRequired = true, + ), inputType = AgeInputType.Age(value = TextFieldValue("56"), unit = TimeUnitValues.YEARS), - state = InputShellState.ERROR, - isRequired = true, - - onValueChanged = { - // no-op - }, + inputState = InputShellState.ERROR, ), + onValueChanged = { + // no-op + }, ) } ColumnComponentContainer("Input Age Component - Legend") { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + isRequired = true, + ), inputType = AgeInputType.Age(value = TextFieldValue("56"), unit = TimeUnitValues.YEARS), - state = InputShellState.ERROR, - isRequired = true, - - onValueChanged = { - // no-op - }, + inputState = InputShellState.ERROR, legendData = LegendData(SurfaceColor.CustomGreen, "Legend", popUpLegendDescriptionData = regularLegendList), ), + onValueChanged = { + // no-op + }, ) } } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/parameter/ParameterSelectorScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/parameter/ParameterSelectorScreen.kt index 794a3ff28..46b103fab 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/parameter/ParameterSelectorScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/parameter/ParameterSelectorScreen.kt @@ -17,7 +17,6 @@ import org.hisp.dhis.mobile.ui.designsystem.component.ColumnScreenContainer import org.hisp.dhis.mobile.ui.designsystem.component.DateTimeActionType import org.hisp.dhis.mobile.ui.designsystem.component.DropdownItem import org.hisp.dhis.mobile.ui.designsystem.component.InputAge -import org.hisp.dhis.mobile.ui.designsystem.component.InputAgeModel import org.hisp.dhis.mobile.ui.designsystem.component.InputBarCode import org.hisp.dhis.mobile.ui.designsystem.component.InputCheckBox import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTime @@ -43,7 +42,9 @@ import org.hisp.dhis.mobile.ui.designsystem.component.parameter.model.ParameterS import org.hisp.dhis.mobile.ui.designsystem.component.parameter.model.ParameterSelectorItemModel.Status.CLOSED import org.hisp.dhis.mobile.ui.designsystem.component.parameter.model.ParameterSelectorItemModel.Status.FOCUSED import org.hisp.dhis.mobile.ui.designsystem.component.parameter.model.ParameterSelectorItemModel.Status.UNFOCUSED +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputAgeData import org.hisp.dhis.mobile.ui.designsystem.component.state.InputDateTimeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputAgeState import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputDateTimeState import org.hisp.dhis.mobile.ui.designsystem.resource.provideDHIS2Icon import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor @@ -134,14 +135,16 @@ fun ParameterSelectorScreen() { helper = "Optional", inputField = { InputAge( - InputAgeModel( - title = "Age parameter", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Age parameter", + inputStyle = InputStyle.ParameterInputStyle(), + ), inputType = ageInputType, - inputStyle = InputStyle.ParameterInputStyle(), - onValueChanged = { - ageInputType = it - }, ), + onValueChanged = { + ageInputType = it ?: AgeInputType.None + }, ) }, status = when (ageInputType) { diff --git a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/InputAgeSnapshotTest.kt b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/InputAgeSnapshotTest.kt index 0b5d7bf56..c4b79d1ca 100644 --- a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/InputAgeSnapshotTest.kt +++ b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/InputAgeSnapshotTest.kt @@ -4,10 +4,11 @@ import androidx.compose.ui.text.input.TextFieldValue import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType import org.hisp.dhis.mobile.ui.designsystem.component.ColumnScreenContainer import org.hisp.dhis.mobile.ui.designsystem.component.InputAge -import org.hisp.dhis.mobile.ui.designsystem.component.InputAgeModel import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState import org.hisp.dhis.mobile.ui.designsystem.component.SubTitle import org.hisp.dhis.mobile.ui.designsystem.component.TimeUnitValues +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputAgeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputAgeState import org.junit.Rule import org.junit.Test @@ -22,74 +23,96 @@ class InputAgeSnapshotTest { ColumnScreenContainer { SubTitle("Input Age Component - Idle") InputAge( - InputAgeModel( - title = "Label", - inputType = AgeInputType.None, - - onValueChanged = { - }, + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), ), + onValueChanged = { + }, ) SubTitle("Input Age Component - Idle Disabled") InputAge( - InputAgeModel( - title = "Label", - inputType = AgeInputType.None, - state = InputShellState.DISABLED, - onValueChanged = { - }, + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputState = InputShellState.DISABLED, ), + onValueChanged = { + }, ) - SubTitle("Input Age Component - Date Of Birth") + SubTitle("Input Age Component - Invalid Date Of Birth") InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.DateOfBirth( TextFieldValue("01011985"), ), - state = InputShellState.DISABLED, - onValueChanged = { - }, + inputState = InputShellState.DISABLED, + ), + onValueChanged = { + }, + ) + + SubTitle("Input Age Component - Date Of Birth") + InputAge( + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputType = AgeInputType.DateOfBirth( + TextFieldValue("1991-11-27"), + ), + inputState = InputShellState.DISABLED, ), + onValueChanged = { + }, ) SubTitle("Input Age Component - Date Of Birth Required Error") InputAge( - InputAgeModel( - title = "Label", - inputType = AgeInputType.DateOfBirth(TextFieldValue("010")), - state = InputShellState.ERROR, - isRequired = true, - onValueChanged = { - // no-op - }, + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputType = AgeInputType.DateOfBirth( + TextFieldValue("010"), + ), + inputState = InputShellState.ERROR, ), + onValueChanged = { + }, ) SubTitle("Input Age Component - Age Disabled") InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.Age(value = TextFieldValue("56"), unit = TimeUnitValues.YEARS), - state = InputShellState.DISABLED, - onValueChanged = { - }, + inputState = InputShellState.DISABLED, ), + onValueChanged = { + }, ) SubTitle("Input Age Component - Age Required Error") InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.Age(value = TextFieldValue("56"), unit = TimeUnitValues.YEARS), - state = InputShellState.ERROR, - isRequired = true, - onValueChanged = { - // no-op - }, + inputState = InputShellState.ERROR, ), + onValueChanged = { + }, ) } } diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAge.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAge.kt index 1784560f3..b8133c932 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAge.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAge.kt @@ -21,8 +21,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction @@ -36,8 +38,12 @@ import org.hisp.dhis.mobile.ui.designsystem.component.TimeUnitValues.YEARS import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateTransformation.Companion.DATE_MASK import org.hisp.dhis.mobile.ui.designsystem.component.internal.RegExValidations import org.hisp.dhis.mobile.ui.designsystem.component.internal.dateIsInRange +import org.hisp.dhis.mobile.ui.designsystem.component.internal.formatStoredDateToUI +import org.hisp.dhis.mobile.ui.designsystem.component.internal.formatUIDateToStored +import org.hisp.dhis.mobile.ui.designsystem.component.internal.getDateSupportingText +import org.hisp.dhis.mobile.ui.designsystem.component.internal.getSelectableDates import org.hisp.dhis.mobile.ui.designsystem.component.internal.isValidDate -import org.hisp.dhis.mobile.ui.designsystem.component.internal.parseStringDateToMillis +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputAgeState import org.hisp.dhis.mobile.ui.designsystem.resource.provideStringResource import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2LightColorScheme import org.hisp.dhis.mobile.ui.designsystem.theme.Outline @@ -53,6 +59,8 @@ import java.util.Calendar * @param uiModel: data class [InputAgeModel] with all parameters for component. * @param modifier: optional modifier. */ +@Suppress("DEPRECATION") +@Deprecated("This component is deprecated and will be removed in the next release. Use InputAge instead.") @OptIn(ExperimentalMaterial3Api::class) @Composable fun InputAge( @@ -117,6 +125,7 @@ fun InputAge( previousInputType == None && (uiModel.inputType is DateOfBirth || uiModel.inputType is Age) -> { focusRequester.requestFocus() } + else -> { // no-op } @@ -154,6 +163,7 @@ fun InputAge( enabled = uiModel.state != InputShellState.DISABLED, ) } + is DateOfBirth, is Age -> { BasicTextField( modifier = Modifier @@ -255,7 +265,8 @@ fun InputAge( showDatePicker = false if (uiModel.inputType is DateOfBirth) { datePickerState.selectedDateMillis?.let { - val newInputType: AgeInputType = updateDateOfBirth(uiModel.inputType, TextFieldValue(getDate(it), TextRange(getDate(it).length))) + val newInputType: AgeInputType = + updateDateOfBirth(uiModel.inputType, TextFieldValue(getDate(it), TextRange(getDate(it).length))) uiModel.onValueChanged.invoke(newInputType) } } @@ -268,7 +279,6 @@ fun InputAge( ButtonStyle.TEXT, ColorStyle.DEFAULT, uiModel.cancelText ?: provideStringResource("cancel"), - ) { showDatePicker = false } @@ -296,30 +306,315 @@ fun InputAge( } } -private fun transformInputText(inputType: AgeInputType): String { - return when (inputType) { - is Age -> inputType.value.text - is DateOfBirth -> inputType.value.text - None -> "" - } -} +/** + * DHIS2 Input Age + * Input field to enter age. It will format content based on given visual + * transformation. + * component uses Material 3 [DatePicker] + * input formats supported are mentioned in the age input ui model documentation. + * [DatePicker] Input mode will always follow locale format. + * @param state: an [InputAgeState] with all the parameters for the input + * @param modifier: optional modifier. + */ -private fun getTextFieldValue(inputType: AgeInputType): TextFieldValue { - return when (inputType) { - is Age -> TextFieldValue(transformInputText(inputType), inputType.value.selection) - is DateOfBirth -> TextFieldValue(transformInputText(inputType), inputType.value.selection) - None -> TextFieldValue() +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InputAge( + state: InputAgeState, + onValueChanged: (AgeInputType?) -> Unit, + onNextClicked: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val uiData = state.uiData + val inputType = state.inputType + val uiValue = remember(getTextFieldValue(inputType)) { formatStoredDateToUI(getTextFieldValue(inputType), DateTimeActionType.DATE) } + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + + val maxAgeCharLimit = 3 + var showDatePicker by rememberSaveable { mutableStateOf(false) } + + val helperText = remember(inputType) { + if (inputType is Age) { + inputType.unit.value + } else { + null + } } -} + val helperStyle = remember(inputType) { + when (inputType) { + None -> HelperStyle.NONE + is DateOfBirth -> HelperStyle.WITH_DATE_OF_BIRTH_HELPER + is Age -> HelperStyle.WITH_HELPER_AFTER + } + } + val selectableDates = uiData.selectableDates ?: SelectableDates( + MIN_DATE, + SimpleDateFormat(DATE_FORMAT).format(Calendar.getInstance().time), + ) -private fun updateDateOfBirth(inputType: DateOfBirth, newText: TextFieldValue): AgeInputType { - return if (newText.text.length <= DATE_MASK.length) { - inputType.copy(value = newText) + val datePickerState = rememberDatePickerState( + selectableDates = getSelectableDates(selectableDates), + ) + + val calendarButton: (@Composable () -> Unit)? = if (inputType is DateOfBirth) { + @Composable { + SquareIconButton( + modifier = Modifier.testTag("INPUT_AGE_OPEN_CALENDAR_BUTTON"), + icon = { + Icon( + imageVector = Icons.Filled.Event, + contentDescription = null, + ) + }, + onClick = { + focusRequester.requestFocus() + showDatePicker = !showDatePicker + }, + enabled = state.inputState != InputShellState.DISABLED, + ) + } } else { - inputType + null } + + var previousInputType by remember { mutableStateOf(inputType) } + LaunchedEffect(inputType) { + when { + previousInputType == None && (inputType is DateOfBirth || inputType is Age) -> { + focusRequester.requestFocus() + } + + else -> { + // no-op + } + } + + if (previousInputType != inputType) { + previousInputType = inputType + } + } + + val dateOutOfRangeText = "${provideStringResource("date_out_of_range")} (" + + formatStringToDate(selectableDates.initialDate) + " - " + + formatStringToDate(selectableDates.endDate) + ")" + val dateOutOfRangeItem = SupportingTextData( + text = dateOutOfRangeText, + SupportingTextState.ERROR, + ) + val incorrectDateFormatItem = SupportingTextData( + text = provideStringResource("incorrect_date_format"), + SupportingTextState.ERROR, + ) + + val supportingTextList = provideSupportingText( + inputType, + uiValue, + state.supportingText, + dateOutOfRangeItem, + incorrectDateFormatItem, + selectableDates, + ) + + InputShell( + modifier = modifier.testTag("INPUT_AGE").focusRequester(focusRequester), + title = uiData.title, + state = getInputState(supportingTextList, dateOutOfRangeItem, incorrectDateFormatItem, state.inputState), + isRequiredField = uiData.isRequired, + inputField = { + when (inputType) { + None -> { + TextButtonSelector( + modifier = Modifier.testTag("INPUT_AGE_MODE_SELECTOR"), + firstOptionText = uiData.dateOfBirthLabel ?: provideStringResource("date_birth"), + onClickFirstOption = { + onValueChanged.invoke(DateOfBirth.EMPTY) + }, + middleText = uiData.orLabel ?: provideStringResource("or"), + secondOptionText = uiData.ageLabel ?: provideStringResource("age"), + onClickSecondOption = { + onValueChanged.invoke(Age.EMPTY) + }, + enabled = state.inputState != InputShellState.DISABLED, + ) + } + + is DateOfBirth, is Age -> { + BasicTextField( + modifier = Modifier + .testTag("INPUT_AGE_TEXT_FIELD") + .fillMaxWidth(), + inputTextValue = uiValue, + helper = if (helperText != null) provideStringResource(helperText).lowercase() else null, + isSingleLine = true, + helperStyle = helperStyle, + onInputChanged = { newText -> + if ((inputType is Age && newText.text.length > maxAgeCharLimit) || + (inputType is DateOfBirth && newText.text.length > DATE_MASK.length) + ) { + return@BasicTextField + } + manageOnValueChanged(newText, inputType, onValueChanged) + }, + enabled = state.inputState != InputShellState.DISABLED, + state = state.inputState, + keyboardOptions = KeyboardOptions(imeAction = uiData.imeAction, keyboardType = KeyboardType.Number), + onNextClicked = { + if (onNextClicked != null) { + onNextClicked.invoke() + } else { + focusManager.moveFocus(FocusDirection.Down) + } + }, + ) + } + } + }, + primaryButton = { + if (inputType != None && state.inputState != InputShellState.DISABLED) { + IconButton( + modifier = Modifier.testTag("INPUT_AGE_RESET_BUTTON").padding(Spacing.Spacing0), + icon = { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = "Icon Button", + ) + }, + onClick = { + focusRequester.requestFocus() + onValueChanged.invoke(None) + }, + ) + } + }, + secondaryButton = calendarButton, + supportingText = { + supportingTextList.forEach { label -> + SupportingText( + label.text, + label.state, + modifier = Modifier.testTag("INPUT_AGE_SUPPORTING_TEXT"), + ) + } + }, + legend = { + if (inputType is Age) { + TimeUnitSelector( + modifier = Modifier.fillMaxWidth() + .testTag("INPUT_AGE_TIME_UNIT_SELECTOR"), + orientation = Orientation.HORIZONTAL, + optionSelected = YEARS, + enabled = state.inputState != InputShellState.DISABLED, + onClick = { timeUnit -> + onValueChanged.invoke(inputType.copy(unit = timeUnit)) + }, + ) + } + + state.legendData?.let { + Legend(it, Modifier.testTag("INPUT_AGE_LEGEND")) + } + }, + inputStyle = uiData.inputStyle, + ) + + if (showDatePicker) { + MaterialTheme( + colorScheme = DHIS2LightColorScheme.copy( + outlineVariant = Outline.Medium, + ), + ) { + DatePickerDialog( + modifier = Modifier.testTag("DATE_PICKER"), + onDismissRequest = { showDatePicker = false }, + confirmButton = { + Button( + enabled = true, + ButtonStyle.TEXT, + ColorStyle.DEFAULT, + uiData.acceptText ?: provideStringResource("ok"), + ) { + showDatePicker = false + if (inputType is DateOfBirth) { + datePickerState.selectedDateMillis?.let { + val newInputType: AgeInputType = updateDateOfBirth( + inputType, + TextFieldValue(getDate(it), TextRange(getDate(it).length)), + ) + onValueChanged.invoke(newInputType) + } + } + } + }, + colors = datePickerColors(), + dismissButton = { + Button( + enabled = true, + ButtonStyle.TEXT, + ColorStyle.DEFAULT, + uiData.cancelText ?: provideStringResource("cancel"), + ) { + showDatePicker = false + } + }, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = true, + ), + ) { + DatePicker( + title = { + Text( + text = uiData.title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = Spacing.Spacing24, top = Spacing.Spacing24), + ) + }, + state = datePickerState, + showModeToggle = true, + modifier = Modifier.padding(Spacing.Spacing0), + ) + } + } + } +} + +private fun getInputState( + supportingTextList: List, + dateOutOfRangeItem: SupportingTextData, + incorrectDateFormatItem: SupportingTextData, + currentState: InputShellState, +): InputShellState { + return if (supportingTextList.contains(dateOutOfRangeItem) || supportingTextList.contains(incorrectDateFormatItem)) InputShellState.ERROR else currentState +} + +@Composable +private fun provideSupportingText( + inputType: AgeInputType, + uiValue: TextFieldValue, + supportingText: List?, + dateOutOfRangeItem: SupportingTextData, + incorrectDateFormatItem: SupportingTextData, + selectableDates: SelectableDates, +): List { + val supportingTextList = supportingText?.toMutableList() ?: mutableListOf() + + return (inputType as? DateOfBirth)?.value?.let { + getDateSupportingText( + uiValue = uiValue, + selectableDates = selectableDates, + actionType = DateTimeActionType.DATE, + yearRange = IntRange(MIN_YEAR, MAX_YEAR), + supportingTextList = supportingTextList, + dateOutOfRangeItem = dateOutOfRangeItem, + incorrectDateFormatItem = incorrectDateFormatItem, + ) + } ?: supportingTextList } +@Suppress("DEPRECATION") +@Deprecated("This component is deprecated and will be removed in the next release. Use InputDateTime instead.") @Composable private fun provideSupportingText( uiModel: InputAgeModel, @@ -355,7 +650,44 @@ private fun provideSupportingText( } } ?: uiModel.supportingText +private fun manageOnValueChanged(newText: TextFieldValue, inputType: AgeInputType, onValueChanged: (AgeInputType?) -> Unit) { + val allowedCharacters = RegExValidations.DATE_TIME.regex + if (allowedCharacters.containsMatchIn(newText.text) || newText.text.isBlank()) { + when (inputType) { + is Age -> onValueChanged.invoke((inputType as? Age)?.copy(value = newText)) + is DateOfBirth -> onValueChanged.invoke(DateOfBirth(formatUIDateToStored(newText, DateTimeActionType.DATE))) + None -> onValueChanged.invoke(None) + } + } +} + +private fun transformInputText(inputType: AgeInputType): String { + return when (inputType) { + is Age -> inputType.value.text + is DateOfBirth -> inputType.value.text + None -> "" + } +} + +private fun getTextFieldValue(inputType: AgeInputType): TextFieldValue { + return when (inputType) { + is Age -> TextFieldValue(transformInputText(inputType), inputType.value.selection) + is DateOfBirth -> TextFieldValue(transformInputText(inputType), inputType.value.selection) + None -> TextFieldValue() + } +} + +private fun updateDateOfBirth(inputType: DateOfBirth, newText: TextFieldValue): AgeInputType { + return if (newText.text.length <= DATE_MASK.length) { + inputType.copy(value = newText) + } else { + inputType + } +} + internal const val MIN_DATE = "10111901" +internal const val MIN_YEAR = 1901 +internal val MAX_YEAR = Calendar.getInstance().get(Calendar.YEAR) internal const val DATE_FORMAT = "ddMMYYYY" sealed interface AgeInputType { diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTime.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTime.kt index 35ff1aa58..62aa976e9 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTime.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTime.kt @@ -393,7 +393,7 @@ fun InputDateTime( } } -fun getInputState(supportingTextList: List, dateOutOfRangeItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData, currentState: InputShellState): InputShellState { +private fun getInputState(supportingTextList: List, dateOutOfRangeItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData, currentState: InputShellState): InputShellState { return if (supportingTextList.contains(dateOutOfRangeItem) || supportingTextList.contains(incorrectDateFormatItem)) InputShellState.ERROR else currentState } diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtils.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtils.kt index 6bae3eb58..015542824 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtils.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtils.kt @@ -271,7 +271,12 @@ fun getSelectableDates(selectableDates: SelectableDates): androidx.compose.mater @Deprecated("This function is deprecated and will be removed in the next release. Use overloaded fun instead.") @Suppress("DEPRECATION") -fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: SupportingTextData, incorrectHourFormatItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData): List { +fun getSupportingTextList( + uiModel: InputDateTimeModel, + dateOutOfRangeItem: SupportingTextData, + incorrectHourFormatItem: SupportingTextData, + incorrectDateFormatItem: SupportingTextData, +): List { val supportingTextList = mutableListOf() uiModel.supportingText?.forEach { item -> @@ -291,6 +296,7 @@ fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: Suppo uiModel.supportingText } } + DateTimeActionType.DATE_TIME -> { if (uiModel.inputTextFieldValue?.text!!.length == 12) { dateIsInRange = dateIsInRange( @@ -307,6 +313,7 @@ fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: Suppo if (!isValidHourFormat) supportingTextList.add(incorrectHourFormatItem) } } + DateTimeActionType.DATE -> { if (uiModel.inputTextFieldValue?.text!!.length == 8) { dateIsInRange = dateIsInRange(parseStringDateToMillis(uiModel.inputTextFieldValue.text), uiModel.selectableDates, uiModel.format) @@ -328,7 +335,6 @@ fun getSupportingTextList( dateOutOfRangeItem: SupportingTextData, incorrectHourFormatItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData, - ): List { val supportingTextList = state.supportingText?.toMutableList() ?: mutableListOf() @@ -337,22 +343,48 @@ fun getSupportingTextList( DateTimeActionType.TIME -> { getTimeSupportingTextList(uiValue, supportingTextList, incorrectHourFormatItem) } + DateTimeActionType.DATE_TIME -> { - getDateTimeSupportingTextList(uiValue, dateOutOfRangeItem, incorrectDateFormatItem, incorrectHourFormatItem, state, data, supportingTextList) + getDateTimeSupportingTextList( + uiValue, + dateOutOfRangeItem, + incorrectDateFormatItem, + incorrectHourFormatItem, + state, + data, + supportingTextList, + ) } + DateTimeActionType.DATE -> { - getDateSupportingText(uiValue, data, supportingTextList, dateOutOfRangeItem, incorrectDateFormatItem) + getDateSupportingText( + uiValue, + data.selectableDates, + data.actionType, + data.yearRange, + supportingTextList, + dateOutOfRangeItem, + incorrectDateFormatItem, + ) } } } return supportingTextList.toList() } -fun getDateSupportingText(uiValue: TextFieldValue, data: InputDateTimeData, supportingTextList: MutableList, dateOutOfRangeItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData): List { +fun getDateSupportingText( + uiValue: TextFieldValue, + selectableDates: SelectableDates, + actionType: DateTimeActionType, + yearRange: IntRange, + supportingTextList: MutableList, + dateOutOfRangeItem: SupportingTextData, + incorrectDateFormatItem: SupportingTextData, +): List { if (uiValue.text.length == 8) { - val dateIsInRange = dateIsInRange(parseStringDateToMillis(uiValue.text), data.selectableDates) + val dateIsInRange = dateIsInRange(parseStringDateToMillis(uiValue.text), selectableDates) val isValidDateFormat = isValidDate(uiValue.text) - val dateIsInYearRange = yearIsInRange(uiValue.text, getDefaultFormat(data.actionType), data.yearRange) + val dateIsInYearRange = yearIsInRange(uiValue.text, getDefaultFormat(actionType), yearRange) if (!dateIsInRange || !dateIsInYearRange) supportingTextList.add(dateOutOfRangeItem) if (!isValidDateFormat) supportingTextList.add(incorrectDateFormatItem) } @@ -385,7 +417,11 @@ fun getDateTimeSupportingTextList( return supportingTextList } -fun getTimeSupportingTextList(inputTextFieldValue: TextFieldValue?, supportingTextList: MutableList, incorrectHourFormatItem: SupportingTextData): List { +fun getTimeSupportingTextList( + inputTextFieldValue: TextFieldValue?, + supportingTextList: MutableList, + incorrectHourFormatItem: SupportingTextData, +): List { if (inputTextFieldValue?.text!!.length == 4 && !isValidHourFormat(inputTextFieldValue.text)) { supportingTextList.add(incorrectHourFormatItem) } @@ -395,7 +431,10 @@ fun getTimeSupportingTextList(inputTextFieldValue: TextFieldValue?, supportingTe @Composable @OptIn(ExperimentalMaterial3Api::class) internal fun getTimePickerState(state: InputDateTimeState, uiData: InputDateTimeData): TimePickerState { - return if (state.inputTextFieldValue?.text?.isNotEmpty() == true && uiData.actionType == DateTimeActionType.TIME && isValidHourFormat(state.inputTextFieldValue?.text ?: "")) { + return if (state.inputTextFieldValue?.text?.isNotEmpty() == true && uiData.actionType == DateTimeActionType.TIME && isValidHourFormat( + state.inputTextFieldValue?.text ?: "", + ) + ) { rememberTimePickerState( initialHour = state.inputTextFieldValue!!.text.substring(0, 2) .toInt(), @@ -404,7 +443,10 @@ internal fun getTimePickerState(state: InputDateTimeState, uiData: InputDateTime ) } else if (state.inputTextFieldValue?.text?.length == 12 && isValidHourFormat(state.inputTextFieldValue!!.text.substring(8, 12))) { rememberTimePickerState( - initialHour = state.inputTextFieldValue?.text?.substring(state.inputTextFieldValue!!.text.length - 4, state.inputTextFieldValue!!.text.length - 2)!! + initialHour = state.inputTextFieldValue?.text?.substring( + state.inputTextFieldValue!!.text.length - 4, + state.inputTextFieldValue!!.text.length - 2, + )!! .toInt(), state.inputTextFieldValue!!.text.substring(state.inputTextFieldValue!!.text.length - 2, state.inputTextFieldValue!!.text.length).toInt(), is24Hour = uiData.is24hourFormat, diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/InputAgeState.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/InputAgeState.kt new file mode 100644 index 000000000..a4bfc7672 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/InputAgeState.kt @@ -0,0 +1,66 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.state + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.input.ImeAction +import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle +import org.hisp.dhis.mobile.ui.designsystem.component.LegendData +import org.hisp.dhis.mobile.ui.designsystem.component.SelectableDates +import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData + +@Stable +interface InputAgeState { + val uiData: InputAgeData + val inputType: AgeInputType + val inputState: InputShellState + val legendData: LegendData? + val supportingText: List? +} + +@Stable +internal class InputAgeStateImpl( + override val uiData: InputAgeData, + override val inputType: AgeInputType, + override val inputState: InputShellState, + override val legendData: LegendData?, + override val supportingText: List?, +) : InputAgeState + +@Composable +fun rememberInputAgeState( + inputAgeData: InputAgeData, + inputType: AgeInputType = AgeInputType.None, + inputState: InputShellState = InputShellState.UNFOCUSED, + legendData: LegendData? = null, + supportingText: List? = null, +): InputAgeState = remember( + inputType, + inputState, + legendData, + supportingText, +) { + InputAgeStateImpl( + inputAgeData, + inputType, + inputState, + legendData, + supportingText, + ) +} + +data class InputAgeData( + val title: String, + val inputStyle: InputStyle = InputStyle.DataInputStyle(), + val isRequired: Boolean = false, + val imeAction: ImeAction = ImeAction.Next, + val dateOfBirthLabel: String? = null, + val orLabel: String? = null, + val ageLabel: String? = null, + val acceptText: String? = null, + val cancelText: String? = null, + val is24hourFormat: Boolean = false, + val selectableDates: SelectableDates? = null, +) diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAgeTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAgeTest.kt index c3dbd2a6f..fcbcb815f 100644 --- a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAgeTest.kt +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAgeTest.kt @@ -3,11 +3,15 @@ package org.hisp.dhis.mobile.ui.designsystem.component import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.text.input.TextFieldValue +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputAgeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputAgeState import org.junit.Rule import org.junit.Test import java.text.SimpleDateFormat @@ -22,12 +26,14 @@ class InputAgeTest { fun modeSelectionShouldBeShownWhenComponentIsInitialised() { rule.setContent { InputAge( - InputAgeModel( - title = "Label", - onValueChanged = { - // no-op - }, + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), ), + onValueChanged = { + // no-op + }, ) } @@ -42,13 +48,15 @@ class InputAgeTest { fun dateOfBirthFieldShouldBeShownCorrectly() { rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.DateOfBirth.EMPTY, - onValueChanged = { - // no-op - }, ), + onValueChanged = { + // no-op + }, ) } @@ -64,13 +72,15 @@ class InputAgeTest { var inputType by mutableStateOf(AgeInputType.None) rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.DateOfBirth.EMPTY, - onValueChanged = { - inputType = it - }, ), + onValueChanged = { + inputType = it ?: AgeInputType.None + }, ) } @@ -84,13 +94,15 @@ class InputAgeTest { fun ageFieldShouldBeShownCorrectly() { rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.Age.EMPTY, - onValueChanged = { - // no-op - }, ), + onValueChanged = { + // no-op + }, ) } @@ -106,13 +118,15 @@ class InputAgeTest { var inputType by mutableStateOf(AgeInputType.None) rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.Age.EMPTY, - onValueChanged = { - inputType = it - }, ), + onValueChanged = { + inputType = it ?: AgeInputType.None + }, ) } @@ -127,13 +141,15 @@ class InputAgeTest { rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = inputType, - onValueChanged = { - inputType = it - }, ), + onValueChanged = { + inputType = it ?: AgeInputType.None + }, ) } @@ -156,14 +172,15 @@ class InputAgeTest { rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = inputType, - onValueChanged = { - inputType = it - }, ), - + onValueChanged = { + inputType = it ?: AgeInputType.None + }, ) } @@ -178,13 +195,15 @@ class InputAgeTest { rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = inputType, - onValueChanged = { - inputType = it - }, ), + onValueChanged = { + inputType = it ?: AgeInputType.None + }, ) } @@ -206,4 +225,62 @@ class InputAgeTest { assert(newInputDaysType.value.text == "28") assert(newInputDaysType.unit == TimeUnitValues.DAYS) } + + @Test + fun shouldFormatDateCorrectly() { + rule.setContent { + InputAge( + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputType = AgeInputType.DateOfBirth(TextFieldValue("1991-11-27")), + ), + onValueChanged = { + // no-op + }, + ) + } + + rule.onNodeWithTag("INPUT_AGE_TEXT_FIELD").assertExists().assertTextEquals("27/11/1991") + } + + @Test + fun shouldShowErrorForOutsideRangeDate() { + rule.setContent { + InputAge( + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputType = AgeInputType.DateOfBirth(TextFieldValue("2025-11-27")), + ), + onValueChanged = { + // no-op + }, + ) + } + + rule.onNodeWithTag("INPUT_AGE_TEXT_FIELD").assertExists().assertTextEquals("27/11/2025") + rule.onNodeWithTag("INPUT_AGE_SUPPORTING_TEXT").assertExists() + } + + @Test + fun shouldWorkWithInvalidDate() { + rule.setContent { + InputAge( + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputType = AgeInputType.DateOfBirth(TextFieldValue("1004-9999-9999")), + ), + onValueChanged = { + // no-op + }, + ) + } + + rule.onNodeWithTag("INPUT_AGE_TEXT_FIELD").assertExists().assertTextEquals("99/99/9999") + } } diff --git a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_InputAgeSnapshotTest_launchInputAgeSnapshot.png b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_InputAgeSnapshotTest_launchInputAgeSnapshot.png index e093a22a5..4b2f2f983 100644 Binary files a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_InputAgeSnapshotTest_launchInputAgeSnapshot.png and b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_InputAgeSnapshotTest_launchInputAgeSnapshot.png differ