From a77b6d90dbb3e26e7a12bca748caa8217f2c9969 Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Fri, 16 Aug 2024 13:14:20 +0200 Subject: [PATCH] refactpr: [ANDROAPP-6392]: Move logic to date Time utils, create unit tests general clean up and refactor --- .../component/internal/DateTimeUtilsTest.kt | 125 ++++ .../designsystem/component/InputDateTime.kt | 571 +++++------------- .../component/internal/DateTimeUtils.kt | 375 +++++++++++- .../component/internal/StringUtils.kt | 9 + 4 files changed, 645 insertions(+), 435 deletions(-) create mode 100644 designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtilsTest.kt 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..8abb725c7 --- /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/InputDateTime.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTime.kt index 5feea9a65..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,9 +55,19 @@ 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 @@ -68,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 @@ -383,135 +393,15 @@ fun InputDateTime( } } -@Suppress("deprecation") -@Deprecated("This function is deprecated and will be removed once new implementation is added to the capture app. Use overloaded fun instead.") -@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) - } - } +fun getInputState(supportingTextList: List, dateOutOfRangeItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData, currentState: InputShellState): InputShellState { + return if (supportingTextList.contains(dateOutOfRangeItem) || supportingTextList.contains(incorrectDateFormatItem)) InputShellState.ERROR else currentState } -@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( - supportingText: List?, - inputTextFieldValue: TextFieldValue?, - actionType: DateTimeActionType, - dateOutOfRangeItem: SupportingTextData, - incorrectHourFormatItem: SupportingTextData, - incorrectDateFormatItem: SupportingTextData, - selectableDates: SelectableDates, - yearRange: IntRange, -): List { - val supportingTextList = mutableListOf() - - supportingText?.forEach { item -> - supportingTextList.add(item) - } - if (!inputTextFieldValue?.text.isNullOrEmpty()) { - val dateIsInRange: Boolean - val dateIsInYearRange: Boolean - val isValidHourFormat: Boolean - val isValidDateFormat: Boolean - - when (actionType) { - DateTimeActionType.TIME -> { - if (inputTextFieldValue?.text!!.length == 4) { - isValidHourFormat = isValidHourFormat(inputTextFieldValue.text) - if (!isValidHourFormat) supportingTextList.add(incorrectHourFormatItem) - supportingText - } - } - DateTimeActionType.DATE_TIME -> { - if (inputTextFieldValue?.text!!.length == 12) { - dateIsInRange = dateIsInRange( - parseStringDateToMillis( - inputTextFieldValue.text.substring(0, inputTextFieldValue.text.length - 4), - ), - selectableDates, - ) - dateIsInYearRange = yearIsInRange(inputTextFieldValue.text, getDefaultFormat(actionType), yearRange) - isValidHourFormat = isValidHourFormat(inputTextFieldValue.text.substring(8, 12)) - isValidDateFormat = isValidDate(inputTextFieldValue.text.substring(0, 8)) - if (!dateIsInRange || !dateIsInYearRange) supportingTextList.add(dateOutOfRangeItem) - if (!isValidDateFormat) supportingTextList.add(incorrectDateFormatItem) - if (!isValidHourFormat) supportingTextList.add(incorrectHourFormatItem) - } - } - DateTimeActionType.DATE -> { - if (inputTextFieldValue?.text!!.length == 8) { - dateIsInRange = dateIsInRange(parseStringDateToMillis(inputTextFieldValue.text), selectableDates) - isValidDateFormat = isValidDate(inputTextFieldValue.text) - dateIsInYearRange = yearIsInRange(inputTextFieldValue.text, getDefaultFormat(actionType), yearRange) - if (!dateIsInRange || !dateIsInYearRange) supportingTextList.add(dateOutOfRangeItem) - if (!isValidDateFormat) supportingTextList.add(incorrectDateFormatItem) - } - } - } +fun getActionButtonIcon(actionType: DateTimeActionType): ImageVector { + return when (actionType) { + DateTimeActionType.DATE, DateTimeActionType.DATE_TIME -> Icons.Filled.Event + DateTimeActionType.TIME -> Icons.Filled.Schedule } - return supportingTextList.toList() } /** @@ -536,8 +426,7 @@ fun InputDateTime( ) { val uiData = state.uiData - val uiValue = remember(state.inputTextFieldValue) { formatStoredDateToUI(state.inputTextFieldValue?.text ?: "", uiData.actionType) } - val allowedCharacters = RegExValidations.DATE_TIME.regex + 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) } @@ -562,13 +451,13 @@ fun InputDateTime( SupportingTextState.ERROR, ) val supportingTextList = - getSupportingTextList(state.supportingText, uiValue, uiData.actionType, dateOutOfRangeItem, incorrectHourFormatItem, incorrectDateFormatItem, uiData.selectableDates, uiData.yearRange) + getSupportingTextList(state, uiValue, uiData, dateOutOfRangeItem, incorrectHourFormatItem, incorrectDateFormatItem) InputShell( modifier = modifier.testTag("INPUT_DATE_TIME") .focusRequester(focusRequester), title = uiData.title, - state = if (supportingTextList.contains(dateOutOfRangeItem) || supportingTextList.contains(incorrectDateFormatItem)) InputShellState.ERROR else state.inputState, + state = getInputState(supportingTextList, dateOutOfRangeItem, incorrectDateFormatItem, state.inputState), isRequiredField = uiData.isRequired, onFocusChanged = onFocusChanged, inputField = { @@ -584,20 +473,14 @@ fun InputDateTime( return@BasicTextField } - if (allowedCharacters.containsMatchIn(newText.text) || newText.text.isBlank()) { - onValueChanged.invoke(formatUIDateToStored(newText, uiData.actionType)) - } + 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 = { - if (onNextClicked != null) { - onNextClicked.invoke() - } else { - focusManager.moveFocus(FocusDirection.Down) - } + manageOnNext(focusManager, onNextClicked) }, ) } else { @@ -608,11 +491,7 @@ fun InputDateTime( .fillMaxWidth(), text = uiData.visualTransformation.filter(AnnotatedString(uiValue.text)).text, style = MaterialTheme.typography.bodyLarge.copy( - color = if (state.inputState != InputShellState.DISABLED && !state.inputTextFieldValue?.text.isNullOrEmpty()) { - TextColor.OnSurface - } else { - TextColor.OnDisabledSurface - }, + color = getTextColor(state.inputState, state.inputTextFieldValue), ), ) Box( @@ -634,27 +513,10 @@ fun InputDateTime( } }, primaryButton = { - 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() - }, - ) - } + InputDateResetButton(state, onValueChanged, focusRequester) }, secondaryButton = { - val icon = when (uiData.actionType) { - DateTimeActionType.DATE, DateTimeActionType.DATE_TIME -> Icons.Filled.Event - DateTimeActionType.TIME -> Icons.Filled.Schedule - } + val icon = getActionButtonIcon(uiData.actionType) SquareIconButton( modifier = Modifier.testTag("INPUT_DATE_TIME_ACTION_BUTTON") @@ -669,12 +531,10 @@ fun InputDateTime( focusRequester.requestFocus() if (onActionClicked != null) { onActionClicked.invoke() + } else if (uiData.actionType == DateTimeActionType.TIME) { + showTimePicker = !showTimePicker } else { - if (uiData.actionType == DateTimeActionType.TIME) { - showTimePicker = !showTimePicker - } else { - showDatePicker = !showDatePicker - } + showDatePicker = !showDatePicker } }, enabled = state.inputState != InputShellState.DISABLED, @@ -697,7 +557,7 @@ fun InputDateTime( }, inputStyle = uiData.inputStyle, ) - val datePickerState = provideDatePickerState(state.inputTextFieldValue, uiData.actionType, uiData.yearRange, uiData.selectableDates) + val datePickerState = provideDatePickerState(state.inputTextFieldValue, uiData) if (showDatePicker) { MaterialTheme( @@ -760,22 +620,8 @@ fun InputDateTime( } if (showTimePicker) { - var timePickerState = rememberTimePickerState(0, 0, is24Hour = uiData.is24hourFormat) - if (state.inputTextFieldValue?.text?.isNotEmpty() == true && uiData.actionType == DateTimeActionType.TIME && isValidHourFormat(state.inputTextFieldValue?.text ?: "")) { - timePickerState = 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))) { - timePickerState = 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, - ) - } - } + val timePickerState = getTimePickerState(state, uiData) + Dialog( onDismissRequest = { showDatePicker = false }, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true, usePlatformDefaultWidth = true), @@ -816,11 +662,7 @@ fun InputDateTime( uiData.acceptText ?: provideStringResource("ok"), ) { showTimePicker = false - if (uiData.actionType != DateTimeActionType.DATE_TIME) { - onValueChanged(TextFieldValue(getTime(timePickerState), selection = TextRange(state.inputTextFieldValue?.text?.length ?: 0))) - } else { - onValueChanged(TextFieldValue(getDate(datePickerState.selectedDateMillis) + getTime(timePickerState), selection = TextRange(state.inputTextFieldValue?.text?.length ?: 0))) - } + manageOnValueChangedFromDateTimePicker(convertStringToTextFieldValue(getTime(timePickerState)), onValueChanged, uiData.actionType, datePickerState, timePickerState) } } } @@ -828,11 +670,62 @@ fun InputDateTime( } } +@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. Use overloaded fun instead.") +@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) @@ -848,34 +741,63 @@ private fun provideDatePickerState(uiModel: InputDateTimeModel): DatePickerState } ?: rememberDatePickerState(selectableDates = getSelectableDates(uiModel)) } -@Composable @OptIn(ExperimentalMaterial3Api::class) -private fun provideDatePickerState(inputTextFieldValue: TextFieldValue?, actionType: DateTimeActionType, yearRange: IntRange, selectableDates: SelectableDates): DatePickerState { - return inputTextFieldValue?.text?.takeIf { - it.isNotEmpty() && - yearIsInRange(it, getDefaultFormat(actionType), yearRange) - }?.let { - rememberDatePickerState( - initialSelectedDateMillis = parseStringDateToMillis( - dateString = it, - pattern = getDefaultFormat(actionType), - ), - yearRange = yearRange, - selectableDates = getSelectableDates(selectableDates), - ) - } ?: rememberDatePickerState(selectableDates = getSelectableDates(selectableDates)) +@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, + ) } -private fun getDefaultFormat(actionType: DateTimeActionType): String { - return when (actionType) { - DateTimeActionType.DATE -> "ddMMyyyy" - DateTimeActionType.TIME -> "HHmm" - DateTimeActionType.DATE_TIME -> "ddMMyyyyHHmm" +@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 + } } /** @@ -934,220 +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 -} - -fun parseStringDateToMillis(dateString: String): Long { - val cal = Calendar.getInstance() - return dateString.parseDate("ddMMyyyy")?.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 - } -} - -private fun formatStoredDateToUI(inputDateString: String, valueType: DateTimeActionType?): TextFieldValue { - return when (valueType) { - DateTimeActionType.DATE_TIME -> { - val components = inputDateString.split("T") - if (components.size != 2) { - return getTextfieldValue(inputDateString) - } - - val date = components[0].split("-") - if (date.size < 3) { - return getTextfieldValue(inputDateString) - } - - val year = date[0] - val month = date[1] - val day = date[2] - - val time = components[1].split(":") - if (components.size != 2) { - return getTextfieldValue(inputDateString) - } - - val hours = time[0] - val minutes = time[1].substring(0, 2) - - val returnValue = "$day$month$year$hours$minutes" - TextFieldValue(returnValue, TextRange(returnValue.length)) - } - - DateTimeActionType.TIME -> { - val components = inputDateString.split(":") - if (components.size != 2) { - return getTextfieldValue(inputDateString) - } - val hours = components[0] - val minutes = components[1] - val timeValue = "$hours$minutes" - - getTextfieldValue(timeValue) - } - - else -> { - val components = inputDateString.split("-") - if (components.size != 3) { - return getTextfieldValue(inputDateString) - } - - val year = components[0] - val month = components[1] - val day = components[2] - val dateValue = "$day$month$year" - getTextfieldValue(dateValue) - } - } -} - -private fun getTextfieldValue(inputDateString: String?): TextFieldValue { - inputDateString?.let { - return TextFieldValue(inputDateString, TextRange(inputDateString.length)) - } - return TextFieldValue() -} - -private fun formatUIDateToStored(inputDateString: TextFieldValue?, valueType: DateTimeActionType?): TextFieldValue? { - val inputDateString = inputDateString?.text - return when (valueType) { - DateTimeActionType.DATE_TIME -> { - if (inputDateString?.length != 12) { - getTextfieldValue(inputDateString) - } 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" - getTextfieldValue(dateTimeValue) - } - } - - DateTimeActionType.TIME -> { - if (inputDateString?.length != 4 && inputDateString?.length != 12) { - getTextfieldValue(inputDateString) - } else { - val minutes = inputDateString.substring(2, 4) - val hours = inputDateString.substring(0, 2) - val timeValue = "$hours:$minutes" - getTextfieldValue(timeValue) - } - } - - else -> { - if (inputDateString?.length != 8) { - getTextfieldValue(inputDateString) - } else { - val year = inputDateString.substring(4, 8) - val month = inputDateString.substring(2, 4) - val day = inputDateString.substring(0, 2) - val dateValue = "$year-$month-$day" - getTextfieldValue(dateValue) - } - } - } -} - -@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 a9fd82bd9..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,21 +1,102 @@ 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 -@SuppressWarnings("deprecation") -@Deprecated("This class is internal and should not be used replace with the new implementation") +@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) && @@ -23,6 +104,14 @@ internal fun dateIsInRange(date: Long, allowedDates: SelectableDates): Boolean { ) } +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 { @@ -50,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()),