From c6a7e03e92ef3f12dd4fbde836b8aec508e6731b Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Fri, 23 Aug 2024 14:43:18 +0200 Subject: [PATCH] feat: [ANDROAPP-6392] Unify Date return values (#284) --- .../actionInputs/InputDateTimeScreen.kt | 151 +++-- .../parameter/ParameterSelectorScreen.kt | 22 +- .../component/internal/DateTimeUtilsTest.kt | 125 ++++ .../ui/designsystem/component/InputAge.kt | 1 + .../designsystem/component/InputDateTime.kt | 548 +++++++++++++----- .../component/internal/DateTimeUtils.kt | 380 +++++++++++- .../component/internal/StringUtils.kt | 9 + .../component/state/InputDateTimeState.kt | 73 +++ .../component/InputDateTimeTest.kt | 104 ++-- 9 files changed, 1152 insertions(+), 261 deletions(-) create mode 100644 designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtilsTest.kt create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/InputDateTimeState.kt diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/actionInputs/InputDateTimeScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/actionInputs/InputDateTimeScreen.kt index 4462bba47..2148f89d9 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/actionInputs/InputDateTimeScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/actionInputs/InputDateTimeScreen.kt @@ -11,125 +11,164 @@ 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.DateTimeActionType import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTime -import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTimeModel import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState import org.hisp.dhis.mobile.ui.designsystem.component.SelectableDates import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateTimeTransformation import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateTransformation import org.hisp.dhis.mobile.ui.designsystem.component.internal.TimeTransformation +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputDateTimeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputDateTimeState @Composable fun InputDateTimeScreen() { ColumnScreenContainer(title = ActionInputs.INPUT_DATE_TIME.label) { - var date by remember { mutableStateOf(TextFieldValue("18122024", selection = TextRange(8))) } - var time by remember { mutableStateOf(TextFieldValue("0930")) } - var dateTime by remember { mutableStateOf(TextFieldValue("121119910230")) } - var dateTime24hour by remember { mutableStateOf(TextFieldValue("121119911930")) } + var date by remember { mutableStateOf(TextFieldValue("2024-11-12", selection = TextRange(8))) } + var time by remember { mutableStateOf(TextFieldValue("09:30")) } + var dateTime by remember { mutableStateOf(TextFieldValue("1991-11-12T02:30")) } + var dateTime24hour by remember { mutableStateOf(TextFieldValue("1991-11-12T19:30")) } - var dateTimenoInput by remember { mutableStateOf(TextFieldValue("11112014")) } - var hour24time by remember { mutableStateOf(TextFieldValue("1630")) } + var dateTimenoInput by remember { mutableStateOf(TextFieldValue("09:30")) } + var hour24time by remember { mutableStateOf(TextFieldValue("16:30")) } ColumnComponentContainer("Date Input (allowed dates from 01/09/2024 to 12/12/2024)") { InputDateTime( - InputDateTimeModel( - title = "Label", + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = DateTransformation(), + actionType = DateTimeActionType.DATE, + selectableDates = SelectableDates("01092024", "12122024"), + ), inputTextFieldValue = date, - visualTransformation = DateTransformation(), - actionType = DateTimeActionType.DATE, - onValueChanged = { date = it ?: TextFieldValue() }, - selectableDates = SelectableDates("01092024", "12122024"), ), + + onValueChanged = { date = it ?: TextFieldValue() }, + ) } ColumnComponentContainer("Time Input") { InputDateTime( - InputDateTimeModel( - title = "Label", - inputTextFieldValue = dateTimenoInput, - visualTransformation = DateTransformation(), - actionType = DateTimeActionType.DATE, - onValueChanged = { dateTimenoInput = it ?: TextFieldValue() }, - allowsManualInput = false, + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = TimeTransformation(), + actionType = DateTimeActionType.TIME, + allowsManualInput = false, + ), + inputTextFieldValue = time, ), + + onValueChanged = { dateTimenoInput = it ?: TextFieldValue() }, + ) } ColumnComponentContainer("24 hour format Time Input") { InputDateTime( - InputDateTimeModel( - - title = "Label", + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = TimeTransformation(), + actionType = DateTimeActionType.TIME, + is24hourFormat = true, + ), inputTextFieldValue = hour24time, - visualTransformation = TimeTransformation(), - actionType = DateTimeActionType.TIME, - onValueChanged = { hour24time = it ?: TextFieldValue() }, - is24hourFormat = true, ), + + onValueChanged = { hour24time = it ?: TextFieldValue() }, ) } ColumnComponentContainer("12 hour format Time Input") { InputDateTime( - InputDateTimeModel( - title = "Label", + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = TimeTransformation(), + actionType = DateTimeActionType.TIME, + is24hourFormat = false, + ), inputTextFieldValue = time, - visualTransformation = TimeTransformation(), - actionType = DateTimeActionType.TIME, - onValueChanged = { time = it ?: TextFieldValue() }, ), + + onValueChanged = { time = it ?: TextFieldValue() }, ) } ColumnComponentContainer("Date-Time Input") { InputDateTime( - InputDateTimeModel( - title = "Label", + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = DateTimeTransformation(), + actionType = DateTimeActionType.DATE_TIME, + ), inputTextFieldValue = dateTime, - visualTransformation = DateTimeTransformation(), - actionType = DateTimeActionType.DATE_TIME, - onValueChanged = { dateTime = it ?: TextFieldValue() }, ), + + onValueChanged = { dateTime = it ?: TextFieldValue() }, ) } ColumnComponentContainer("Date-Time Input 24 hour ") { InputDateTime( - InputDateTimeModel( - title = "Label", + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = DateTimeTransformation(), + actionType = DateTimeActionType.DATE_TIME, + is24hourFormat = true, + ), inputTextFieldValue = dateTime24hour, - visualTransformation = DateTimeTransformation(), - actionType = DateTimeActionType.DATE_TIME, - onValueChanged = { dateTime24hour = it ?: TextFieldValue() }, - is24hourFormat = true, ), + + onValueChanged = { dateTime24hour = it ?: TextFieldValue() }, ) } ColumnComponentContainer("Disabled") { InputDateTime( - InputDateTimeModel( - title = "Label", + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = DateTimeTransformation(), + actionType = DateTimeActionType.DATE_TIME, + ), inputTextFieldValue = TextFieldValue(), - state = InputShellState.DISABLED, - onValueChanged = { - // no-op - }, + inputState = InputShellState.DISABLED, ), + + onValueChanged = { + // no-op + }, ) } ColumnComponentContainer("Error") { InputDateTime( - InputDateTimeModel( - title = "Label", + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = DateTimeTransformation(), + actionType = DateTimeActionType.DATE_TIME, + isRequired = true, + ), inputTextFieldValue = TextFieldValue(), - isRequired = true, - state = InputShellState.ERROR, - onValueChanged = { - // no-op - }, + inputState = InputShellState.ERROR, ), + + 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 765563424..794a3ff28 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 @@ -14,13 +14,13 @@ import androidx.compose.ui.text.input.TextFieldValue import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType import org.hisp.dhis.mobile.ui.designsystem.component.CheckBoxData 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 -import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTimeModel import org.hisp.dhis.mobile.ui.designsystem.component.InputDropDown import org.hisp.dhis.mobile.ui.designsystem.component.InputEmail import org.hisp.dhis.mobile.ui.designsystem.component.InputInteger @@ -36,12 +36,15 @@ 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.InputText import org.hisp.dhis.mobile.ui.designsystem.component.RadioButtonData +import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateTimeTransformation import org.hisp.dhis.mobile.ui.designsystem.component.internal.ImageCardData import org.hisp.dhis.mobile.ui.designsystem.component.parameter.ParameterSelectorItem import org.hisp.dhis.mobile.ui.designsystem.component.parameter.model.ParameterSelectorItemModel 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.InputDateTimeData +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 @@ -201,13 +204,20 @@ fun ParameterSelectorScreen() { helper = "Optional", inputField = { InputDateTime( - InputDateTimeModel( - title = "DateTime parameter", - inputStyle = InputStyle.ParameterInputStyle(), + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "DateTime parameter", + visualTransformation = DateTimeTransformation(), + actionType = DateTimeActionType.DATE_TIME, + inputStyle = InputStyle.ParameterInputStyle(), + ), inputTextFieldValue = TextFieldValue(), - onValueChanged = {}, - format = "ddMMYYYY", ), + + onValueChanged = { + // no op + }, ) }, onExpand = {}, diff --git a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtilsTest.kt b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtilsTest.kt new file mode 100644 index 000000000..4ef331377 --- /dev/null +++ b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtilsTest.kt @@ -0,0 +1,125 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.internal + +import androidx.compose.ui.text.input.TextFieldValue +import org.hisp.dhis.mobile.ui.designsystem.component.DateTimeActionType +import org.hisp.dhis.mobile.ui.designsystem.component.SelectableDates +import org.junit.Test + +class DateTimeUtilsTest { + + @Test + fun shouldReturnTrueIfDateIsWithinSelectedDatesRangeAndFalseIfNot() { + var selectedDates = SelectableDates( + initialDate = "01011990", + endDate = "01012040", + ) + assert(dateIsInRange(System.currentTimeMillis(), selectedDates)) + selectedDates = SelectableDates( + initialDate = "01011990", + endDate = "01011993", + ) + assert(!dateIsInRange(System.currentTimeMillis(), selectedDates)) + } + + @Test + fun shouldFormatDateTimeValueTypeStoredDateToUiCorrectly() { + var storedValue = TextFieldValue("2022-10-12T20:25") + assert(formatStoredDateToUI(storedValue, DateTimeActionType.DATE_TIME).text == "121020222025") + + storedValue = TextFieldValue("2022-10-1") + assert(formatStoredDateToUI(storedValue, DateTimeActionType.DATE_TIME).text == "2022-10-1") + + storedValue = TextFieldValue("2022-10-1") + assert(formatStoredDateToUI(storedValue, DateTimeActionType.DATE_TIME).text == "2022-10-1") + + storedValue = TextFieldValue("2022-10-10T20") + assert(formatStoredDateToUI(storedValue, DateTimeActionType.DATE_TIME).text == "2022-10-10T20") + } + + @Test + fun shouldFormatDateValueTypeStoredDateToUiCorrectly() { + var storedValue = TextFieldValue("2022-10-12") + assert(formatStoredDateToUI(storedValue, DateTimeActionType.DATE).text == "12102022") + + storedValue = TextFieldValue("2022-10") + assert(formatStoredDateToUI(storedValue, DateTimeActionType.DATE).text == "2022-10") + + storedValue = TextFieldValue("2022-10-1") + assert(formatStoredDateToUI(storedValue, DateTimeActionType.DATE).text == "1102022") + + storedValue = TextFieldValue("2022-10-10") + assert(formatStoredDateToUI(storedValue, DateTimeActionType.DATE).text == "10102022") + } + + @Test + fun shouldFormatTimeValueTypeStoredDateToUiCorrectly() { + var storedValue = TextFieldValue("20:00") + assert(formatStoredDateToUI(storedValue, DateTimeActionType.TIME).text == "2000") + storedValue = TextFieldValue("20") + assert(formatStoredDateToUI(storedValue, DateTimeActionType.TIME).text == "20") + storedValue = TextFieldValue("20:0") + assert(formatStoredDateToUI(storedValue, DateTimeActionType.TIME).text == "200") + } + + @Test + fun shouldNotAllowInvalidFormatDates() { + assert(!isValidDate("Function…")) + assert(isValidDate("28022020")) + assert(!isValidDate("99999999")) + assert(isValidDate("12119999")) + assert(!isValidDate("12559999")) + assert(!isValidDate("55129999")) + assert(isValidDate("12111991")) + } + + @Test + fun shouldFormatUiValueToStoredCorrectly() { + assert( + formatUIDateToStored( + convertStringToTextFieldValue("2002"), + DateTimeActionType.TIME, + ).text == "20:02", + ) + + assert( + formatUIDateToStored( + convertStringToTextFieldValue("200"), + DateTimeActionType.TIME, + ).text == "200", + ) + + assert( + formatUIDateToStored( + convertStringToTextFieldValue("1230"), + DateTimeActionType.TIME, + ).text == "12:30", + ) + + assert( + formatUIDateToStored( + convertStringToTextFieldValue("12111991"), + DateTimeActionType.DATE, + ).text == "1991-11-12", + ) + assert( + formatUIDateToStored( + convertStringToTextFieldValue("1211199"), + DateTimeActionType.DATE, + ).text == "1211199", + ) + + assert( + formatUIDateToStored( + convertStringToTextFieldValue("121119911730"), + DateTimeActionType.DATE_TIME, + ).text == "1991-11-12T17:30", + ) + + assert( + formatUIDateToStored( + convertStringToTextFieldValue("12111991173"), + DateTimeActionType.DATE_TIME, + ).text == "12111991173", + ) + } +} 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 77a9e584e..1784560f3 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 @@ -37,6 +37,7 @@ import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateTransformatio 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.isValidDate +import org.hisp.dhis.mobile.ui.designsystem.component.internal.parseStringDateToMillis 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 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 1afea6cca..35ff1aa58 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 @@ -24,8 +24,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TimePicker -import androidx.compose.material3.TimePickerColors -import androidx.compose.material3.TimePickerDefaults import androidx.compose.material3.TimePickerLayoutType import androidx.compose.material3.TimePickerState import androidx.compose.material3.rememberDatePickerState @@ -40,8 +38,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.AnnotatedString @@ -54,10 +55,21 @@ import androidx.compose.ui.window.DialogProperties import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateTimeVisualTransformation import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateTransformation 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.isValidDate +import org.hisp.dhis.mobile.ui.designsystem.component.internal.convertStringToTextFieldValue +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.getDefaultFormat +import org.hisp.dhis.mobile.ui.designsystem.component.internal.getSelectableDates +import org.hisp.dhis.mobile.ui.designsystem.component.internal.getSupportingTextList +import org.hisp.dhis.mobile.ui.designsystem.component.internal.getTime +import org.hisp.dhis.mobile.ui.designsystem.component.internal.getTimePickerState import org.hisp.dhis.mobile.ui.designsystem.component.internal.isValidHourFormat +import org.hisp.dhis.mobile.ui.designsystem.component.internal.parseDate +import org.hisp.dhis.mobile.ui.designsystem.component.internal.parseStringDateToMillis +import org.hisp.dhis.mobile.ui.designsystem.component.internal.provideDatePickerState +import org.hisp.dhis.mobile.ui.designsystem.component.internal.timePickerColors import org.hisp.dhis.mobile.ui.designsystem.component.internal.yearIsInRange +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputDateTimeState 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 @@ -67,7 +79,6 @@ import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor import java.text.SimpleDateFormat import java.util.Calendar -import java.util.Date import java.util.GregorianCalendar import java.util.Locale import java.util.TimeZone @@ -82,6 +93,9 @@ import java.util.TimeZone * @param uiModel: an [InputDateTimeModel] with all the parameters for the input * @param modifier: optional modifier. */ + +@Suppress("DEPRECATION") +@Deprecated("This component is deprecated and will be removed in the next release. Use InputDateTime instead.") @OptIn(ExperimentalMaterial3Api::class) @Composable fun InputDateTime( @@ -379,68 +393,339 @@ fun InputDateTime( } } -@OptIn(ExperimentalMaterial3Api::class) -fun getSelectableDates(uiModel: InputDateTimeModel): androidx.compose.material3.SelectableDates { - return object : androidx.compose.material3.SelectableDates { - override fun isSelectableDate(utcTimeMillis: Long): Boolean { - return dateIsInRange(utcTimeMillis, uiModel.selectableDates, uiModel.format) - } +fun getInputState(supportingTextList: List, dateOutOfRangeItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData, currentState: InputShellState): InputShellState { + return if (supportingTextList.contains(dateOutOfRangeItem) || supportingTextList.contains(incorrectDateFormatItem)) InputShellState.ERROR else currentState +} + +fun getActionButtonIcon(actionType: DateTimeActionType): ImageVector { + return when (actionType) { + DateTimeActionType.DATE, DateTimeActionType.DATE_TIME -> Icons.Filled.Event + DateTimeActionType.TIME -> Icons.Filled.Schedule } } -fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: SupportingTextData, incorrectHourFormatItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData): List { - val supportingTextList = mutableListOf() +/** + * DHIS2 Input Date Time + * Input field to enter date, time or date&time. It will format content based on given visual + * transformation. + * component uses Material 3 [DatePicker] and [TimePicker] + * input formats supported are mentioned in the date time input ui model documentation. + * [DatePicker] Input mode will always follow locale format. + * @param uiModel: an [InputDateTimeModel] with all the parameters for the input + * @param modifier: optional modifier. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InputDateTime( + state: InputDateTimeState, + onFocusChanged: ((Boolean) -> Unit) = {}, + onValueChanged: (TextFieldValue?) -> Unit, + onNextClicked: (() -> Unit)? = null, + onActionClicked: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val uiData = state.uiData - uiModel.supportingText?.forEach { item -> - supportingTextList.add(item) - } - if (!uiModel.inputTextFieldValue?.text.isNullOrEmpty()) { - val dateIsInRange: Boolean - val dateIsInYearRange: Boolean - val isValidHourFormat: Boolean - val isValidDateFormat: Boolean - - when (uiModel.actionType) { - DateTimeActionType.TIME -> { - if (uiModel.inputTextFieldValue?.text!!.length == 4) { - isValidHourFormat = isValidHourFormat(uiModel.inputTextFieldValue.text) - if (!isValidHourFormat) supportingTextList.add(incorrectHourFormatItem) - uiModel.supportingText - } - } - DateTimeActionType.DATE_TIME -> { - if (uiModel.inputTextFieldValue?.text!!.length == 12) { - dateIsInRange = dateIsInRange( - parseStringDateToMillis( - uiModel.inputTextFieldValue.text.substring(0, uiModel.inputTextFieldValue.text.length - 4), + val uiValue = remember(state.inputTextFieldValue) { formatStoredDateToUI(state.inputTextFieldValue ?: TextFieldValue(), uiData.actionType) } + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + var showDatePicker by rememberSaveable { mutableStateOf(false) } + var showTimePicker by rememberSaveable { mutableStateOf(false) } + var dateOutOfRangeText = uiData.outOfRangeText ?: provideStringResource("date_out_of_range") + + dateOutOfRangeText = "$dateOutOfRangeText (" + formatStringToDate( + uiData.selectableDates.initialDate, + ) + " - " + + formatStringToDate(uiData.selectableDates.endDate) + ")" + val incorrectHourFormatTextdd = uiData.incorrectHourFormatText ?: provideStringResource("wrong_hour_format") + val incorrectHourFormatItem = SupportingTextData( + text = incorrectHourFormatTextdd, + SupportingTextState.ERROR, + ) + val incorrectDateFormatItem = SupportingTextData( + text = provideStringResource("incorrect_date_format"), + SupportingTextState.ERROR, + ) + val dateOutOfRangeItem = SupportingTextData( + text = dateOutOfRangeText, + SupportingTextState.ERROR, + ) + val supportingTextList = + getSupportingTextList(state, uiValue, uiData, dateOutOfRangeItem, incorrectHourFormatItem, incorrectDateFormatItem) + + InputShell( + modifier = modifier.testTag("INPUT_DATE_TIME") + .focusRequester(focusRequester), + title = uiData.title, + state = getInputState(supportingTextList, dateOutOfRangeItem, incorrectDateFormatItem, state.inputState), + isRequiredField = uiData.isRequired, + onFocusChanged = onFocusChanged, + inputField = { + if (uiData.allowsManualInput) { + BasicTextField( + modifier = Modifier + .testTag("INPUT_DATE_TIME_TEXT_FIELD") + .fillMaxWidth(), + inputTextValue = uiValue, + isSingleLine = true, + onInputChanged = { newText -> + if (newText.text.length > uiData.visualTransformation.maskLength) { + return@BasicTextField + } + + manageOnValueChanged(newText, onValueChanged, uiData.actionType) + }, + enabled = state.inputState != InputShellState.DISABLED, + state = state.inputState, + keyboardOptions = KeyboardOptions(imeAction = uiData.imeAction, keyboardType = KeyboardType.Number), + visualTransformation = uiData.visualTransformation, + onNextClicked = { + manageOnNext(focusManager, onNextClicked) + }, + ) + } else { + Box { + Text( + modifier = Modifier + .testTag("INPUT_DATE_TIME_TEXT") + .fillMaxWidth(), + text = uiData.visualTransformation.filter(AnnotatedString(uiValue.text)).text, + style = MaterialTheme.typography.bodyLarge.copy( + color = getTextColor(state.inputState, state.inputTextFieldValue), ), - uiModel.selectableDates, uiModel.format, ) - dateIsInYearRange = yearIsInRange(uiModel.inputTextFieldValue.text, getDefaultFormat(uiModel.actionType), uiModel.yearRange) - isValidHourFormat = isValidHourFormat(uiModel.inputTextFieldValue.text.substring(8, 12)) - isValidDateFormat = isValidDate(uiModel.inputTextFieldValue.text.substring(0, 8)) - if (!dateIsInRange || !dateIsInYearRange) supportingTextList.add(dateOutOfRangeItem) - if (!isValidDateFormat) supportingTextList.add(incorrectDateFormatItem) - if (!isValidHourFormat) supportingTextList.add(incorrectHourFormatItem) + Box( + modifier = Modifier + .matchParentSize() + .alpha(0f) + .clickable( + enabled = state.inputState != InputShellState.DISABLED, + onClick = { + if (uiData.actionType == DateTimeActionType.TIME) { + showTimePicker = !showTimePicker + } else { + showDatePicker = !showDatePicker + } + }, + ), + ) } } - DateTimeActionType.DATE -> { - if (uiModel.inputTextFieldValue?.text!!.length == 8) { - dateIsInRange = dateIsInRange(parseStringDateToMillis(uiModel.inputTextFieldValue.text), uiModel.selectableDates, uiModel.format) - isValidDateFormat = isValidDate(uiModel.inputTextFieldValue.text) - dateIsInYearRange = yearIsInRange(uiModel.inputTextFieldValue.text, getDefaultFormat(uiModel.actionType), uiModel.yearRange) - if (!dateIsInRange || !dateIsInYearRange) supportingTextList.add(dateOutOfRangeItem) - if (!isValidDateFormat) supportingTextList.add(incorrectDateFormatItem) + }, + primaryButton = { + InputDateResetButton(state, onValueChanged, focusRequester) + }, + secondaryButton = { + val icon = getActionButtonIcon(uiData.actionType) + + SquareIconButton( + modifier = Modifier.testTag("INPUT_DATE_TIME_ACTION_BUTTON") + .focusable(), + icon = { + Icon( + imageVector = icon, + contentDescription = null, + ) + }, + onClick = { + focusRequester.requestFocus() + if (onActionClicked != null) { + onActionClicked.invoke() + } else if (uiData.actionType == DateTimeActionType.TIME) { + showTimePicker = !showTimePicker + } else { + showDatePicker = !showDatePicker + } + }, + enabled = state.inputState != InputShellState.DISABLED, + ) + }, + supportingText = + { + supportingTextList.forEach { item -> + SupportingText( + item.text, + item.state, + modifier = Modifier.testTag("INPUT_DATE_TIME_SUPPORTING_TEXT" + item.text), + ) + } + }, + legend = { + state.legendData?.let { + Legend(it, Modifier.testTag("INPUT_DATE_TIME_LEGEND")) + } + }, + inputStyle = uiData.inputStyle, + ) + val datePickerState = provideDatePickerState(state.inputTextFieldValue, uiData) + + 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 (uiData.actionType != DateTimeActionType.DATE_TIME) { + datePickerState.selectedDateMillis?.let { + onValueChanged(TextFieldValue(getDate(it), selection = TextRange(state.inputTextFieldValue?.text?.length ?: 0))) + } + } else { + showTimePicker = true + } + } + }, + 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), + ) + } + } + } + + if (showTimePicker) { + val timePickerState = getTimePickerState(state, uiData) + + Dialog( + onDismissRequest = { showDatePicker = false }, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true, usePlatformDefaultWidth = true), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.background( + color = SurfaceColor.Container, + shape = RoundedCornerShape(Radius.L), + ).testTag("TIME_PICKER") + .padding(vertical = Spacing.Spacing16, horizontal = Spacing.Spacing24), + ) { + Text( + text = uiData.title, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(bottom = Spacing.Spacing16).align(Alignment.Start), + ) + TimePicker( + state = timePickerState, + layoutType = TimePickerLayoutType.Vertical, + colors = timePickerColors(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Row(Modifier.align(Alignment.End)) { + Button( + enabled = true, + ButtonStyle.TEXT, + ColorStyle.DEFAULT, + uiData.cancelText ?: provideStringResource("cancel"), + + ) { + showTimePicker = false + } + Button( + enabled = true, + ButtonStyle.TEXT, + ColorStyle.DEFAULT, + uiData.acceptText ?: provideStringResource("ok"), + ) { + showTimePicker = false + manageOnValueChangedFromDateTimePicker(convertStringToTextFieldValue(getTime(timePickerState)), onValueChanged, uiData.actionType, datePickerState, timePickerState) + } } } } } - return supportingTextList.toList() } +@Composable +fun InputDateResetButton(state: InputDateTimeState, onValueChanged: (TextFieldValue?) -> Unit, focusRequester: FocusRequester) { + if (!state.inputTextFieldValue?.text.isNullOrBlank() && state.inputState != InputShellState.DISABLED) { + IconButton( + modifier = Modifier.testTag("INPUT_DATE_TIME_RESET_BUTTON").padding(Spacing.Spacing0), + icon = { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = "Icon Button", + ) + }, + onClick = { + onValueChanged.invoke(TextFieldValue()) + focusRequester.requestFocus() + }, + ) + } +} + +fun getTextColor(inputState: InputShellState, inputTextFieldValue: TextFieldValue?): Color { + return if (inputState != InputShellState.DISABLED && !inputTextFieldValue?.text.isNullOrEmpty()) { + TextColor.OnSurface + } else { + TextColor.OnDisabledSurface + } +} + +fun manageOnNext(focusManager: FocusManager, onNextClicked: (() -> Unit)?) { + if (onNextClicked != null) { + onNextClicked.invoke() + } else { + focusManager.moveFocus(FocusDirection.Down) + } +} + +private fun manageOnValueChanged(newText: TextFieldValue, onValueChanged: (TextFieldValue?) -> Unit, actionType: DateTimeActionType) { + val allowedCharacters = RegExValidations.DATE_TIME.regex + if (allowedCharacters.containsMatchIn(newText.text) || newText.text.isBlank()) { + onValueChanged.invoke(formatUIDateToStored(newText, actionType)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +private fun manageOnValueChangedFromDateTimePicker(newValue: TextFieldValue?, onValueChanged: (TextFieldValue?) -> Unit, actionType: DateTimeActionType, datePickerState: DatePickerState, timePickerState: TimePickerState) { + if (actionType != DateTimeActionType.DATE_TIME) { + onValueChanged(TextFieldValue(getTime(timePickerState), selection = TextRange(newValue?.text?.length ?: 0))) + } else { + onValueChanged(TextFieldValue(getDate(datePickerState.selectedDateMillis) + getTime(timePickerState), selection = TextRange(newValue?.text?.length ?: 0))) + } +} + +@Suppress("deprecation") +@Deprecated("This function is deprecated and will be removed in the next release.", replaceWith = ReplaceWith("provideDatePickerState(state: InputDateTimeState, data: InputDateTimeData)")) @Composable @OptIn(ExperimentalMaterial3Api::class) -private fun provideDatePickerState(uiModel: InputDateTimeModel): DatePickerState { +internal fun provideDatePickerState(uiModel: InputDateTimeModel): DatePickerState { return uiModel.inputTextFieldValue?.text?.takeIf { it.isNotEmpty() && yearIsInRange(it, getDefaultFormat(uiModel.actionType), uiModel.yearRange) @@ -456,16 +741,63 @@ private fun provideDatePickerState(uiModel: InputDateTimeModel): DatePickerState } ?: rememberDatePickerState(selectableDates = getSelectableDates(uiModel)) } -private fun getDefaultFormat(actionType: DateTimeActionType): String { - return when (actionType) { - DateTimeActionType.DATE -> "ddMMyyyy" - DateTimeActionType.TIME -> "HHmm" - DateTimeActionType.DATE_TIME -> "ddMMyyyyHHmm" +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun datePickerColors(): DatePickerColors { + return DatePickerDefaults.colors( + selectedDayContainerColor = SurfaceColor.Primary, + selectedDayContentColor = TextColor.OnPrimary, + todayDateBorderColor = SurfaceColor.Primary, + selectedYearContainerColor = SurfaceColor.Primary, + selectedYearContentColor = TextColor.OnPrimary, + disabledDayContentColor = TextColor.OnDisabledSurface, + ) +} + +@Deprecated("This function is deprecated and will be removed in the near future", replaceWith = ReplaceWith("parseStringDateToMillis(dateString: String, pattern: String)")) +fun parseStringDateToMillis(dateString: String, pattern: String = "ddMMyyyy"): Long { + val cal = Calendar.getInstance() + return dateString.parseDate(pattern)?.let { + cal.time = it + cal.timeInMillis + } ?: 0L +} + +internal fun getDate(milliSeconds: Long?, format: String? = "ddMMyyyy"): String { + val cal = Calendar.getInstance() + val currentTimeZone: TimeZone = cal.getTimeZone() + val currentDt: Calendar = GregorianCalendar(currentTimeZone, Locale.getDefault()) + var gmtOffset: Int = currentTimeZone.getOffset( + currentDt[Calendar.ERA], + currentDt[Calendar.YEAR], + currentDt[Calendar.MONTH], + currentDt[Calendar.DAY_OF_MONTH], + currentDt[Calendar.DAY_OF_WEEK], + currentDt[Calendar.MILLISECOND], + ) + gmtOffset /= (60 * 60 * 1000) + cal.add(Calendar.HOUR_OF_DAY, +gmtOffset) + return if (milliSeconds != null) { + cal.timeInMillis = milliSeconds + val formater = SimpleDateFormat(format) + if (gmtOffset < 0) { + var day = formater.format(cal.time).substring(0, 2).toInt() + day += 1 + formater.format(cal.time).replaceRange(0, 2, String.format("%02d", day)) + } else { + formater.format(cal.time) + } + } else { + "" } } -enum class DateTimeActionType { - DATE, TIME, DATE_TIME +fun formatStringToDate(dateString: String): String { + return if (dateString.length == 8) { + dateString.substring(0, 2) + "/" + dateString.substring(2, 4) + "/" + dateString.substring(4, 8) + } else { + dateString + } } /** @@ -524,107 +856,11 @@ data class InputDateTimeModel( val incorrectHourFormatText: String? = null, ) -internal fun getDate(milliSeconds: Long?, format: String? = "ddMMyyyy"): String { - val cal = Calendar.getInstance() - val currentTimeZone: TimeZone = cal.getTimeZone() - val currentDt: Calendar = GregorianCalendar(currentTimeZone, Locale.getDefault()) - var gmtOffset: Int = currentTimeZone.getOffset( - currentDt[Calendar.ERA], - currentDt[Calendar.YEAR], - currentDt[Calendar.MONTH], - currentDt[Calendar.DAY_OF_MONTH], - currentDt[Calendar.DAY_OF_WEEK], - currentDt[Calendar.MILLISECOND], - ) - gmtOffset /= (60 * 60 * 1000) - cal.add(Calendar.HOUR_OF_DAY, +gmtOffset) - return if (milliSeconds != null) { - cal.timeInMillis = milliSeconds - val formater = SimpleDateFormat(format) - if (gmtOffset < 0) { - var day = formater.format(cal.time).substring(0, 2).toInt() - day += 1 - formater.format(cal.time).replaceRange(0, 2, String.format("%02d", day)) - } else { - formater.format(cal.time) - } - } else { - "" - } -} - -@OptIn(ExperimentalMaterial3Api::class) -private fun getTime(timePickerState: TimePickerState, format: String? = "HHmm"): String { - val cal = Calendar.getInstance() - cal.set(Calendar.HOUR_OF_DAY, timePickerState.hour) - cal.set(Calendar.MINUTE, timePickerState.minute) - cal.set(Calendar.SECOND, 0) - cal.set(Calendar.MILLISECOND, 0) - - val formater = SimpleDateFormat(format) - return formater.format(cal.time) -} - -fun parseStringDateToMillis(dateString: String, pattern: String = "ddMMyyyy"): Long { - val cal = Calendar.getInstance() - return dateString.parseDate(pattern)?.let { - cal.time = it - cal.timeInMillis - } ?: 0L -} - data class SelectableDates( val initialDate: String, val endDate: String, ) -fun formatStringToDate(dateString: String): String { - return if (dateString.length == 8) { - dateString.substring(0, 2) + "/" + dateString.substring(2, 4) + "/" + dateString.substring(4, 8) - } else { - dateString - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun timePickerColors(): TimePickerColors { - return TimePickerDefaults.colors( - containerColor = SurfaceColor.Container, - clockDialColor = SurfaceColor.ContainerHigh, - clockDialUnselectedContentColor = TextColor.OnSurface, - clockDialSelectedContentColor = TextColor.OnPrimary, - timeSelectorSelectedContentColor = TextColor.OnPrimaryContainer, - timeSelectorUnselectedContainerColor = SurfaceColor.ContainerHigh, - timeSelectorUnselectedContentColor = TextColor.OnSurface, - periodSelectorSelectedContainerColor = SurfaceColor.WarningContainer, - periodSelectorUnselectedContentColor = TextColor.OnSurfaceVariant, - periodSelectorSelectedContentColor = SurfaceColor.Warning, - periodSelectorUnselectedContainerColor = SurfaceColor.Container, - selectorColor = SurfaceColor.Primary, - timeSelectorSelectedContainerColor = SurfaceColor.ContainerLow, - ) -} - -fun String.parseDate(pattern: String): Date? { - return if (isNotEmpty() && length == pattern.length) { - val sdf = SimpleDateFormat(pattern, Locale.getDefault()) - sdf.timeZone = TimeZone.getTimeZone("UTC") - sdf.parse(this) - } else { - null - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun datePickerColors(): DatePickerColors { - return DatePickerDefaults.colors( - selectedDayContainerColor = SurfaceColor.Primary, - selectedDayContentColor = TextColor.OnPrimary, - todayDateBorderColor = SurfaceColor.Primary, - selectedYearContainerColor = SurfaceColor.Primary, - selectedYearContentColor = TextColor.OnPrimary, - disabledDayContentColor = TextColor.OnDisabledSurface, - ) +enum class DateTimeActionType { + DATE, TIME, DATE_TIME } 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 1e2388bde..6bae3eb58 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 @@ -1,19 +1,117 @@ package org.hisp.dhis.mobile.ui.designsystem.component.internal +import androidx.compose.material3.DatePickerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TimePickerColors +import androidx.compose.material3.TimePickerDefaults +import androidx.compose.material3.TimePickerState +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.input.TextFieldValue +import org.hisp.dhis.mobile.ui.designsystem.component.DateTimeActionType +import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTimeModel import org.hisp.dhis.mobile.ui.designsystem.component.SelectableDates -import org.hisp.dhis.mobile.ui.designsystem.component.parseDate -import org.hisp.dhis.mobile.ui.designsystem.component.parseStringDateToMillis +import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputDateTimeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputDateTimeState +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor import java.text.ParseException import java.text.SimpleDateFormat import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone +@Suppress("DEPRECATION") +@Deprecated( + "This function is deprecated and will be removed in the near future replace with." + + " New implementation does not take format as a parameter.", + replaceWith = ReplaceWith("dateIsInRange(date, allowedDates: SelectableDates)"), +) internal fun dateIsInRange(date: Long, allowedDates: SelectableDates, format: String = "ddMMyyyy"): Boolean { return ( - date >= parseStringDateToMillis(allowedDates.initialDate, format) && - date <= parseStringDateToMillis(allowedDates.endDate, format) + date >= parseStringDateToMillis(allowedDates.initialDate) && + date <= parseStringDateToMillis(allowedDates.endDate) ) } +internal fun formatStoredDateToUI(textFieldValue: TextFieldValue, valueType: DateTimeActionType?): TextFieldValue { + try { + return when (valueType) { + DateTimeActionType.DATE_TIME -> { + val components = textFieldValue.text.split("T") + if (components.size != 2) { + return textFieldValue + } + + val date = components[0].split("-") + if (date.size < 3) { + return textFieldValue + } + + val year = date[0] + val month = date[1] + val day = date[2] + + val time = components[1].split(":") + if (components.size != 2) { + return textFieldValue + } + + val hours = time[0] + val minutes = time[1].substring(0, 2) + + val returnValue = "$day$month$year$hours$minutes" + TextFieldValue(returnValue, textFieldValue.selection, textFieldValue.composition) + } + + DateTimeActionType.TIME -> { + val components = textFieldValue.text.split(":") + if (components.size != 2) { + return textFieldValue + } + val hours = components[0] + val minutes = components[1] + val timeValue = "$hours$minutes" + + TextFieldValue(timeValue, textFieldValue.selection, textFieldValue.composition) + } + + else -> { + val components = textFieldValue.text.split("-") + if (components.size != 3) { + return textFieldValue + } + + val year = components[0] + val month = components[1] + val day = components[2] + val dateValue = "$day$month$year" + TextFieldValue(dateValue, textFieldValue.selection, textFieldValue.composition) + } + } + } catch (e: Exception) { + return textFieldValue + } +} + +internal fun dateIsInRange(date: Long, allowedDates: SelectableDates): Boolean { + return ( + date >= parseStringDateToMillis(allowedDates.initialDate) && + date <= parseStringDateToMillis(allowedDates.endDate) + ) +} + +fun parseStringDateToMillis(dateString: String): Long { + val cal = Calendar.getInstance() + return dateString.parseDate("ddMMyyyy")?.let { + cal.time = it + cal.timeInMillis + } ?: 0L +} + internal fun yearIsInRange(date: String, pattern: String, yearRange: IntRange): Boolean { val cal = Calendar.getInstance() return date.parseDate(pattern)?.let { @@ -41,3 +139,277 @@ internal fun isValidDate(text: String): Boolean { false } } + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun provideDatePickerState(inputTextFieldValue: TextFieldValue?, data: InputDateTimeData): DatePickerState { + return inputTextFieldValue?.text?.takeIf { + it.isNotEmpty() && + yearIsInRange(it, getDefaultFormat(data.actionType), data.yearRange) + }?.let { + rememberDatePickerState( + initialSelectedDateMillis = parseStringDateToMillis( + dateString = it, + ), + yearRange = data.yearRange, + selectableDates = getSelectableDates(data.selectableDates), + ) + } ?: rememberDatePickerState(selectableDates = getSelectableDates(data.selectableDates)) +} + +internal fun getDefaultFormat(actionType: DateTimeActionType): String { + return when (actionType) { + DateTimeActionType.DATE -> "ddMMyyyy" + DateTimeActionType.TIME -> "HHmm" + DateTimeActionType.DATE_TIME -> "ddMMyyyyHHmm" + } +} + +internal fun formatUIDateToStored(textFieldValue: TextFieldValue, valueType: DateTimeActionType?): TextFieldValue { + val inputDateString = textFieldValue.text + return when (valueType) { + DateTimeActionType.DATE_TIME -> { + if (inputDateString.length != 12) { + textFieldValue + } else { + val minutes = inputDateString.substring(10, 12) + val hours = inputDateString.substring(8, 10) + val year = inputDateString.substring(4, 8) + val month = inputDateString.substring(2, 4) + val day = inputDateString.substring(0, 2) + val dateTimeValue = "$year-$month-$day" + "T$hours:$minutes" + TextFieldValue(dateTimeValue, textFieldValue.selection, textFieldValue.composition) + } + } + + DateTimeActionType.TIME -> { + if (inputDateString.length != 4 && inputDateString.length != 12) { + textFieldValue + } else { + val minutes = inputDateString.substring(2, 4) + val hours = inputDateString.substring(0, 2) + val timeValue = "$hours:$minutes" + TextFieldValue(timeValue, textFieldValue.selection, textFieldValue.composition) + } + } + + else -> { + if (inputDateString.length != 8) { + textFieldValue + } else { + val year = inputDateString.substring(4, 8) + val month = inputDateString.substring(2, 4) + val day = inputDateString.substring(0, 2) + val dateValue = "$year-$month-$day" + TextFieldValue(dateValue, textFieldValue.selection, textFieldValue.composition) + } + } + } +} + +fun String.parseDate(pattern: String): Date? { + return if (isNotEmpty() && length == pattern.length) { + val sdf = SimpleDateFormat(pattern, Locale.getDefault()) + sdf.timeZone = TimeZone.getTimeZone("UTC") + sdf.parse(this) + } else { + null + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun timePickerColors(): TimePickerColors { + return TimePickerDefaults.colors( + containerColor = SurfaceColor.Container, + clockDialColor = SurfaceColor.ContainerHigh, + clockDialUnselectedContentColor = TextColor.OnSurface, + clockDialSelectedContentColor = TextColor.OnPrimary, + timeSelectorSelectedContentColor = TextColor.OnPrimaryContainer, + timeSelectorUnselectedContainerColor = SurfaceColor.ContainerHigh, + timeSelectorUnselectedContentColor = TextColor.OnSurface, + periodSelectorSelectedContainerColor = SurfaceColor.WarningContainer, + periodSelectorUnselectedContentColor = TextColor.OnSurfaceVariant, + periodSelectorSelectedContentColor = SurfaceColor.Warning, + periodSelectorUnselectedContainerColor = SurfaceColor.Container, + selectorColor = SurfaceColor.Primary, + timeSelectorSelectedContainerColor = SurfaceColor.ContainerLow, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +internal fun getTime(timePickerState: TimePickerState, format: String? = "HHmm"): String { + val cal = Calendar.getInstance() + cal.set(Calendar.HOUR_OF_DAY, timePickerState.hour) + cal.set(Calendar.MINUTE, timePickerState.minute) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + + val formater = SimpleDateFormat(format) + return formater.format(cal.time) +} + +@Suppress("deprecation") +@Deprecated("This function is deprecated and will be removed once new implementation is added to the capture app. ") +@OptIn(ExperimentalMaterial3Api::class) +fun getSelectableDates(uiModel: InputDateTimeModel): androidx.compose.material3.SelectableDates { + return object : androidx.compose.material3.SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + return dateIsInRange(utcTimeMillis, uiModel.selectableDates, uiModel.format) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +fun getSelectableDates(selectableDates: SelectableDates): androidx.compose.material3.SelectableDates { + return object : androidx.compose.material3.SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + return dateIsInRange(utcTimeMillis, selectableDates) + } + } +} + +@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 { + val supportingTextList = mutableListOf() + + uiModel.supportingText?.forEach { item -> + supportingTextList.add(item) + } + if (!uiModel.inputTextFieldValue?.text.isNullOrEmpty()) { + val dateIsInRange: Boolean + val dateIsInYearRange: Boolean + val isValidHourFormat: Boolean + val isValidDateFormat: Boolean + + when (uiModel.actionType) { + DateTimeActionType.TIME -> { + if (uiModel.inputTextFieldValue?.text!!.length == 4) { + isValidHourFormat = isValidHourFormat(uiModel.inputTextFieldValue.text) + if (!isValidHourFormat) supportingTextList.add(incorrectHourFormatItem) + uiModel.supportingText + } + } + DateTimeActionType.DATE_TIME -> { + if (uiModel.inputTextFieldValue?.text!!.length == 12) { + dateIsInRange = dateIsInRange( + parseStringDateToMillis( + uiModel.inputTextFieldValue.text.substring(0, uiModel.inputTextFieldValue.text.length - 4), + ), + uiModel.selectableDates, uiModel.format, + ) + dateIsInYearRange = yearIsInRange(uiModel.inputTextFieldValue.text, getDefaultFormat(uiModel.actionType), uiModel.yearRange) + isValidHourFormat = isValidHourFormat(uiModel.inputTextFieldValue.text.substring(8, 12)) + isValidDateFormat = isValidDate(uiModel.inputTextFieldValue.text.substring(0, 8)) + if (!dateIsInRange || !dateIsInYearRange) supportingTextList.add(dateOutOfRangeItem) + if (!isValidDateFormat) supportingTextList.add(incorrectDateFormatItem) + if (!isValidHourFormat) supportingTextList.add(incorrectHourFormatItem) + } + } + DateTimeActionType.DATE -> { + if (uiModel.inputTextFieldValue?.text!!.length == 8) { + dateIsInRange = dateIsInRange(parseStringDateToMillis(uiModel.inputTextFieldValue.text), uiModel.selectableDates, uiModel.format) + isValidDateFormat = isValidDate(uiModel.inputTextFieldValue.text) + dateIsInYearRange = yearIsInRange(uiModel.inputTextFieldValue.text, getDefaultFormat(uiModel.actionType), uiModel.yearRange) + if (!dateIsInRange || !dateIsInYearRange) supportingTextList.add(dateOutOfRangeItem) + if (!isValidDateFormat) supportingTextList.add(incorrectDateFormatItem) + } + } + } + } + return supportingTextList.toList() +} + +fun getSupportingTextList( + state: InputDateTimeState, + uiValue: TextFieldValue, + data: InputDateTimeData, + dateOutOfRangeItem: SupportingTextData, + incorrectHourFormatItem: SupportingTextData, + incorrectDateFormatItem: SupportingTextData, + +): List { + val supportingTextList = state.supportingText?.toMutableList() ?: mutableListOf() + + if (uiValue.text.isNotEmpty()) { + when (data.actionType) { + DateTimeActionType.TIME -> { + getTimeSupportingTextList(uiValue, supportingTextList, incorrectHourFormatItem) + } + DateTimeActionType.DATE_TIME -> { + getDateTimeSupportingTextList(uiValue, dateOutOfRangeItem, incorrectDateFormatItem, incorrectHourFormatItem, state, data, supportingTextList) + } + DateTimeActionType.DATE -> { + getDateSupportingText(uiValue, data, supportingTextList, dateOutOfRangeItem, incorrectDateFormatItem) + } + } + } + return supportingTextList.toList() +} + +fun getDateSupportingText(uiValue: TextFieldValue, data: InputDateTimeData, supportingTextList: MutableList, dateOutOfRangeItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData): List { + if (uiValue.text.length == 8) { + val dateIsInRange = dateIsInRange(parseStringDateToMillis(uiValue.text), data.selectableDates) + val isValidDateFormat = isValidDate(uiValue.text) + val dateIsInYearRange = yearIsInRange(uiValue.text, getDefaultFormat(data.actionType), data.yearRange) + if (!dateIsInRange || !dateIsInYearRange) supportingTextList.add(dateOutOfRangeItem) + if (!isValidDateFormat) supportingTextList.add(incorrectDateFormatItem) + } + return supportingTextList +} + +fun getDateTimeSupportingTextList( + uiValue: TextFieldValue, + dateOutOfRangeItem: SupportingTextData, + incorrectDateFormatItem: SupportingTextData, + incorrectHourFormatItem: SupportingTextData, + state: InputDateTimeState, + data: InputDateTimeData, + supportingTextList: MutableList, +): List { + if (uiValue.text.length == 12) { + val dateIsInRange = dateIsInRange( + parseStringDateToMillis( + state.inputTextFieldValue!!.text.substring(0, state.inputTextFieldValue!!.text.length - 4), + ), + data.selectableDates, + ) + val dateIsInYearRange = yearIsInRange(uiValue.text, getDefaultFormat(data.actionType), data.yearRange) + val isValidHourFormat = isValidHourFormat(uiValue.text.substring(8, 12)) + val isValidDateFormat = isValidDate(uiValue.text.substring(0, 8)) + if (!dateIsInRange || !dateIsInYearRange) supportingTextList.add(dateOutOfRangeItem) + if (!isValidDateFormat) supportingTextList.add(incorrectDateFormatItem) + if (!isValidHourFormat) supportingTextList.add(incorrectHourFormatItem) + } + return supportingTextList +} + +fun getTimeSupportingTextList(inputTextFieldValue: TextFieldValue?, supportingTextList: MutableList, incorrectHourFormatItem: SupportingTextData): List { + if (inputTextFieldValue?.text!!.length == 4 && !isValidHourFormat(inputTextFieldValue.text)) { + supportingTextList.add(incorrectHourFormatItem) + } + return supportingTextList +} + +@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 ?: "")) { + rememberTimePickerState( + initialHour = state.inputTextFieldValue!!.text.substring(0, 2) + .toInt(), + state.inputTextFieldValue?.text!!.substring(2, 4).toInt(), + is24Hour = uiData.is24hourFormat, + ) + } 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)!! + .toInt(), + state.inputTextFieldValue!!.text.substring(state.inputTextFieldValue!!.text.length - 2, state.inputTextFieldValue!!.text.length).toInt(), + is24Hour = uiData.is24hourFormat, + ) + } else { + rememberTimePickerState(0, 0, is24Hour = uiData.is24hourFormat) + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/StringUtils.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/StringUtils.kt index 15b96eaf4..853064a87 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/StringUtils.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/StringUtils.kt @@ -1,8 +1,10 @@ package org.hisp.dhis.mobile.ui.designsystem.component.internal import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2SCustomTextStyles @@ -267,6 +269,13 @@ class DateTimeTransformation : DateTimeVisualTransformation { } } +internal fun convertStringToTextFieldValue(inputDateString: String?): TextFieldValue { + inputDateString?.let { + return TextFieldValue(inputDateString, TextRange(inputDateString.length)) + } + return TextFieldValue() +} + enum class RegExValidations(val regex: Regex) { BRITISH_DECIMAL_NOTATION("""^(?!\.)(?!.*-[^0-9])(?!(?:[^.]*\.){3})[-0-9]*(?:\.[0-9]*)?$""".toRegex()), EUROPEAN_DECIMAL_NOTATION("""^(?!.*,.+,|.*-.*-)[0-9,-]*$""".toRegex()), diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/InputDateTimeState.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/InputDateTimeState.kt new file mode 100644 index 000000000..e5ddb5df3 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/InputDateTimeState.kt @@ -0,0 +1,73 @@ +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 androidx.compose.ui.text.input.TextFieldValue +import org.hisp.dhis.mobile.ui.designsystem.component.DateTimeActionType +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 +import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateTimeVisualTransformation + +@Stable +interface InputDateTimeState { + val uiData: InputDateTimeData + val inputTextFieldValue: TextFieldValue? + val inputState: InputShellState + val legendData: LegendData? + val supportingText: List? +} + +@Stable +internal class InputDateTimeStateImpl( + override val uiData: InputDateTimeData, + override val inputTextFieldValue: TextFieldValue, + override val inputState: InputShellState, + override val legendData: LegendData?, + override val supportingText: List?, + +) : InputDateTimeState + +@Composable +fun rememberInputDateTimeState( + inputDateTimeData: InputDateTimeData, + inputTextFieldValue: TextFieldValue = TextFieldValue(), + inputState: InputShellState = InputShellState.UNFOCUSED, + legendData: LegendData? = null, + supportingText: List? = null, +): InputDateTimeState = remember( + inputTextFieldValue, + inputState, + legendData, + supportingText, +) { + InputDateTimeStateImpl( + inputDateTimeData, + inputTextFieldValue, + inputState, + legendData, + supportingText, + ) +} + +data class InputDateTimeData( + val title: String, + val inputStyle: InputStyle = InputStyle.DataInputStyle(), + val imeAction: ImeAction = ImeAction.Next, + val isRequired: Boolean = false, + val actionType: DateTimeActionType = DateTimeActionType.DATE_TIME, + val allowsManualInput: Boolean = true, + val visualTransformation: DateTimeVisualTransformation, + val is24hourFormat: Boolean = false, + val acceptText: String? = null, + val cancelText: String? = null, + val outOfRangeText: String? = null, + val incorrectHourFormatText: String? = null, + val selectableDates: SelectableDates = SelectableDates("01011940", "12312300"), + val yearRange: IntRange = IntRange(1970, 2100), + +) diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTimeTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTimeTest.kt index f69d33f67..e2aeb3456 100644 --- a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTimeTest.kt +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTimeTest.kt @@ -8,9 +8,11 @@ import androidx.compose.ui.test.performTextInput import androidx.compose.ui.text.input.TextFieldValue import org.hisp.dhis.mobile.ui.designsystem.component.DateTimeActionType import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTime -import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTimeModel import org.hisp.dhis.mobile.ui.designsystem.component.SelectableDates import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateTransformation +import org.hisp.dhis.mobile.ui.designsystem.component.internal.TimeTransformation +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputDateTimeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputDateTimeState import org.junit.Rule import org.junit.Test @@ -24,15 +26,20 @@ class InputDateTimeTest { var input by mutableStateOf(TextFieldValue()) rule.setContent { InputDateTime( - InputDateTimeModel( - title = "Label", + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = DateTransformation(), + actionType = DateTimeActionType.DATE, + ), inputTextFieldValue = input, - onValueChanged = { - input = it ?: TextFieldValue() - }, - format = "ddMMYYYY", ), + onValueChanged = { + input = it ?: TextFieldValue() + }, + ) } @@ -47,17 +54,20 @@ class InputDateTimeTest { rule.setContent { InputDateTime( - InputDateTimeModel( - title = "Label", + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = DateTransformation(), + actionType = DateTimeActionType.DATE, + ), inputTextFieldValue = input, - onValueChanged = - { - input = it ?: TextFieldValue() - }, - format = "ddMMYYYY", - ), + onValueChanged = { + input = it ?: TextFieldValue() + }, + ) } @@ -66,20 +76,24 @@ class InputDateTimeTest { @Test fun clickingOnResetButtonShouldClearInput() { - var input by mutableStateOf(TextFieldValue("1002")) + var input by mutableStateOf(TextFieldValue("10:02")) rule.setContent { InputDateTime( - InputDateTimeModel( - title = "Label", + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = TimeTransformation(), + actionType = DateTimeActionType.TIME, + ), inputTextFieldValue = input, - onValueChanged = { - input = it ?: TextFieldValue() - }, - format = "HHMM", - actionType = DateTimeActionType.TIME, ), + onValueChanged = { + input = it ?: TextFieldValue() + }, + ) } @@ -90,19 +104,25 @@ class InputDateTimeTest { @Test fun clickingOnActionButtonForDateInputShouldShowDatePicker() { - var input by mutableStateOf(TextFieldValue("10021991")) + var input by mutableStateOf(TextFieldValue("1991-10-21")) rule.setContent { InputDateTime( - InputDateTimeModel( - title = "Label", + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = DateTransformation(), + actionType = DateTimeActionType.DATE, + selectableDates = SelectableDates("01092024", "12122024"), + ), inputTextFieldValue = input, - visualTransformation = DateTransformation(), - actionType = DateTimeActionType.DATE, - onValueChanged = { input = it ?: TextFieldValue() }, - format = "ddMMyyyy", - selectableDates = SelectableDates("01092024", "12122024"), ), + + onValueChanged = { + input = it ?: TextFieldValue() + }, + ) } @@ -112,19 +132,25 @@ class InputDateTimeTest { @Test fun clickingOnActionButtonForTimeInputShouldShowTimePicker() { - var input by mutableStateOf(TextFieldValue("100219911900")) + var input by mutableStateOf(TextFieldValue("19:00")) rule.setContent { InputDateTime( - InputDateTimeModel( - title = "Label", + state = rememberInputDateTimeState( + inputDateTimeData = + InputDateTimeData( + title = "label", + visualTransformation = TimeTransformation(), + actionType = DateTimeActionType.TIME, + selectableDates = SelectableDates("01092024", "12122024"), + ), inputTextFieldValue = input, - visualTransformation = DateTransformation(), - actionType = DateTimeActionType.TIME, - onValueChanged = { input = it ?: TextFieldValue() }, - format = "ddMMyyyy", - selectableDates = SelectableDates("01092024", "12122024"), ), + + onValueChanged = { + input = it ?: TextFieldValue() + }, + ) }