From 598308f366a43509c74324d28c71326f66e2a626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Miguel=20Rubio?= Date: Wed, 15 May 2024 11:18:26 +0200 Subject: [PATCH 01/12] update README.md latest version to 0.2 (#244) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c4a77956a..a0b906bea 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ In the module **build.gradle.kts**: ```kotlin dependencies { - implementation("org.hisp.dhis.mobile:designsystem:0.2-SNAPSHOT") + implementation("org.hisp.dhis.mobile:designsystem:0.2") } ``` From d4274346efcf23a4785dae180ba0bc4ea1a7f38a Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Wed, 3 Jul 2024 13:17:40 +0200 Subject: [PATCH 02/12] ci: align Sonatype secret name with organization wide name (#264) --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 13edc4c64..6bd16985c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -71,8 +71,8 @@ subprojects { } } -val ossrhUsername: String? = System.getenv("OSSRH_USERNAME") -val ossrhPassword: String? = System.getenv("OSSRH_PASSWORD") +val ossrhUsername: String? = System.getenv("SONATYPE_OSSRH_USERNAME") +val ossrhPassword: String? = System.getenv("SONATYPE_OSSRH_PASSWORD") nexusPublishing { this.repositories { From fb271f5ccffbcd7c1429925cb29c75334f118707 Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Wed, 3 Jul 2024 15:13:26 +0200 Subject: [PATCH 03/12] ci: fix maven publish (#265) * ci: fix maven publish * ci: revert osshr username and password, ktlint, and modify osshr username and password correctly --- .github/workflows/continuous-deployment.yml | 4 ++-- build.gradle.kts | 14 +++++++++++--- designsystem/build.gradle.kts | 12 ++---------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/continuous-deployment.yml b/.github/workflows/continuous-deployment.yml index 958482e7f..3438728e4 100644 --- a/.github/workflows/continuous-deployment.yml +++ b/.github/workflows/continuous-deployment.yml @@ -37,7 +37,7 @@ jobs: - name: Publish to maven run: ./.github/workflows/scripts/publish-maven.sh env: - OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} - OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + OSSRH_USERNAME: ${{ secrets.SONATYPE_OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.SONATYPE_OSSRH_PASSWORD }} SIGNING_PRIVATE_KEY: ${{ secrets.PGP_PRIVATE_KEY }} SIGNING_PASSWORD: ${{ secrets.PGP_PASSPHRASE }} diff --git a/build.gradle.kts b/build.gradle.kts index 6bd16985c..9db369b56 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - +version = "0.2-SNAPSHOT" group = "org.hisp.dhis.mobile" plugins { @@ -13,6 +13,14 @@ plugins { id("io.github.gradle-nexus.publish-plugin") version "1.3.0" } +/** + * Property from the Gradle command line. To remove the snapshot suffix from the version. + */ +if (project.hasProperty("removeSnapshotSuffix")) { + val mainVersion = (version as String).split("-SNAPSHOT")[0] + version = mainVersion +} + allprojects { apply(plugin = "org.jlleitschuh.gradle.ktlint") ktlint { @@ -71,8 +79,8 @@ subprojects { } } -val ossrhUsername: String? = System.getenv("SONATYPE_OSSRH_USERNAME") -val ossrhPassword: String? = System.getenv("SONATYPE_OSSRH_PASSWORD") +val ossrhUsername: String? = System.getenv("OSSRH_USERNAME") +val ossrhPassword: String? = System.getenv("OSSRH_PASSWORD") nexusPublishing { this.repositories { diff --git a/designsystem/build.gradle.kts b/designsystem/build.gradle.kts index 43d36ceac..2ae311c0a 100644 --- a/designsystem/build.gradle.kts +++ b/designsystem/build.gradle.kts @@ -1,5 +1,5 @@ -version = "0.2-SNAPSHOT" -group = "org.hisp.dhis.mobile" +version = rootProject.version +group = rootProject.group plugins { kotlin("multiplatform") @@ -9,14 +9,6 @@ plugins { id("app.cash.paparazzi").version("1.3.3") } -/** - * Property from the Gradle command line. To remove the snapshot suffix from the version. - */ -if (project.hasProperty("removeSnapshotSuffix")) { - val mainVersion = (version as String).split("-SNAPSHOT")[0] - version = mainVersion -} - kotlin { androidTarget { publishLibraryVariants("release") From 2d3d0a1a755369e46bd0a4ef781796f398b716bb Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Thu, 4 Jul 2024 08:40:55 +0200 Subject: [PATCH 04/12] ci: trigger deployment on push on main branch (#266) --- .github/workflows/continuous-deployment.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/continuous-deployment.yml b/.github/workflows/continuous-deployment.yml index 3438728e4..29d571103 100644 --- a/.github/workflows/continuous-deployment.yml +++ b/.github/workflows/continuous-deployment.yml @@ -7,6 +7,7 @@ on: push: branches: - develop + - main # Allows you to run this workflow manually from the Actions tab workflow_dispatch: From 681f3c3616c0d3b2fda6cfed7a92209ef820e26b Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Thu, 4 Jul 2024 10:26:45 +0200 Subject: [PATCH 05/12] ci: modify publish maven script for snapshot generation in 0.2 (#267) --- .github/workflows/scripts/publish-maven.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scripts/publish-maven.sh b/.github/workflows/scripts/publish-maven.sh index c50f11238..8d3ba81ac 100755 --- a/.github/workflows/scripts/publish-maven.sh +++ b/.github/workflows/scripts/publish-maven.sh @@ -3,7 +3,7 @@ set -x branch=$(git rev-parse --abbrev-ref HEAD) if [ "$branch" = "main" ]; then - ./gradlew :designsystem:publishToSonatype closeAndReleaseSonatypeStagingRepository -PremoveSnapshotSuffix + ./gradlew :designsystem:publishToSonatype else ./gradlew :designsystem:publishToSonatype fi \ No newline at end of file From 683f2dde6f9df0dd6fb1e6a9b7442d773b38b39b Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Thu, 4 Jul 2024 11:14:05 +0200 Subject: [PATCH 06/12] fix: [ANDROAPP-6104] add commit patch from develop (#268) --- .../ui/designsystem/component/InputAge.kt | 45 +++++++++++++++++-- .../designsystem/component/InputDateTime.kt | 6 +-- .../ui/designsystem/component/InputAgeTest.kt | 28 ++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) 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 f1582e287..4aefaff9b 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 @@ -39,6 +39,8 @@ 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 import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import java.text.SimpleDateFormat +import java.util.Calendar /** * DHIS2 Input Age component wraps DHIS2 [InputShell]. @@ -71,6 +73,7 @@ fun InputAge( is Age -> HelperStyle.WITH_HELPER_AFTER } } + val selectableDates = uiModel.selectableDates val focusRequester = remember { FocusRequester() } val datePickerState = rememberDatePickerState() @@ -112,10 +115,15 @@ fun InputAge( } } + val supportingText = provideSupportingText(uiModel, selectableDates) + InputShell( modifier = modifier.testTag("INPUT_AGE").focusRequester(focusRequester), title = uiModel.title, - state = uiModel.state, + state = when (supportingText) { + uiModel.supportingText -> uiModel.state + else -> InputShellState.ERROR + }, isRequiredField = uiModel.isRequired, inputField = { when (uiModel.inputType) { @@ -187,7 +195,7 @@ fun InputAge( }, secondaryButton = calendarButton, supportingText = { - uiModel.supportingText?.forEach { label -> + supportingText?.forEach { label -> SupportingText( label.text, label.state, @@ -306,6 +314,34 @@ private fun updateDateOfBirth(inputType: DateOfBirth, newText: TextFieldValue): } } +@Composable +private fun provideSupportingText( + uiModel: InputAgeModel, + selectableDates: SelectableDates, +): List? = + (uiModel.inputType as? DateOfBirth)?.value?.text?.let { + if ( + it.length == DATE_FORMAT.length && + !dateIsInRange(parseStringDateToMillis(it), selectableDates) + ) { + val dateOutOfRangeText = "${provideStringResource("date_out_of_range")} (" + + formatStringToDate(selectableDates.initialDate) + " - " + + formatStringToDate(selectableDates.endDate) + ")" + + listOf( + SupportingTextData( + text = dateOutOfRangeText, + SupportingTextState.ERROR, + ), + ).plus(uiModel.supportingText ?: listOf()) + } else { + uiModel.supportingText + } + } ?: uiModel.supportingText + +internal const val MIN_DATE = "10111901" +internal const val DATE_FORMAT = "ddMMYYYY" + sealed interface AgeInputType { data object None : AgeInputType @@ -361,5 +397,8 @@ data class InputAgeModel( val acceptText: String? = null, val cancelText: String? = null, val onValueChanged: (AgeInputType) -> Unit, - val selectableDates: SelectableDates = SelectableDates("10111901", "12112124"), + val selectableDates: SelectableDates = SelectableDates( + MIN_DATE, + SimpleDateFormat(DATE_FORMAT).format(Calendar.getInstance().time), + ), ) 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 eb062b308..33822bd04 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 @@ -392,7 +392,7 @@ fun InputDateTime( } } -private fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: SupportingTextData, incorrectHourFormatItem: SupportingTextData): List { +fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: SupportingTextData, incorrectHourFormatItem: SupportingTextData): List { val supportingTextList = mutableListOf() uiModel.supportingText?.forEach { item -> @@ -537,7 +537,7 @@ private fun getTime(timePickerState: TimePickerState, format: String? = "HHmm"): return formater.format(cal.time) } -private fun parseStringDateToMillis(dateString: String, pattern: String = "ddMMyyyy", locale: Locale = Locale.getDefault()): Long { +fun parseStringDateToMillis(dateString: String, pattern: String = "ddMMyyyy", locale: Locale = Locale.getDefault()): Long { return if (dateString.isNotEmpty()) { val cal = Calendar.getInstance() val sdf = SimpleDateFormat(pattern, locale) @@ -554,7 +554,7 @@ data class SelectableDates( val endDate: String, ) -private fun formatStringToDate(dateString: String): String { +fun formatStringToDate(dateString: String): String { return if (dateString.length == 8) { dateString.substring(0, 2) + "/" + dateString.substring(2, 4) + "/" + dateString.substring(4, 8) } else { diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAgeTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAgeTest.kt index 3b327004c..fbffaf18c 100644 --- a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAgeTest.kt +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAgeTest.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import org.junit.Rule import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Calendar class InputAgeTest { @@ -148,4 +150,30 @@ class InputAgeTest { rule.onNodeWithTag("INPUT_AGE_OPEN_CALENDAR_BUTTON").assertDoesNotExist() rule.onNodeWithTag("INPUT_AGE_TIME_UNIT_SELECTOR").assertDoesNotExist() } + + @Test + fun shouldShowErrorMessageWhenAgeIsOnFuture() { + val calendar = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) + } + val futureDate = SimpleDateFormat(DATE_FORMAT).format(calendar.time) + var inputType by mutableStateOf(AgeInputType.DateOfBirth.EMPTY) + + rule.setContent { + InputAge( + InputAgeModel( + title = "Label", + inputType = inputType, + onValueChanged = { + inputType = it + }, + ), + + ) + } + + rule.onNodeWithTag("INPUT_AGE_TEXT_FIELD").performTextInput(futureDate) + + rule.onNodeWithTag("INPUT_AGE_SUPPORTING_TEXT").assertExists() + } } From 7c0b2bdeefe9bce9ba36c4bffa125533fa3d6d58 Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Thu, 4 Jul 2024 14:00:09 +0200 Subject: [PATCH 07/12] fix: [ANDROAPP-6271] crash when date format make year out of range (#269) --- .../designsystem/component/InputDateTime.kt | 86 +++++++++++-------- 1 file changed, 50 insertions(+), 36 deletions(-) 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 33822bd04..b4cda5e89 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 @@ -18,6 +18,7 @@ import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerColors import androidx.compose.material3.DatePickerDefaults import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DatePickerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -238,27 +239,7 @@ fun InputDateTime( }, inputStyle = uiModel.inputStyle, ) - var datePickerState = rememberDatePickerState() - if (!uiModel.inputTextFieldValue?.text.isNullOrEmpty() && uiModel.actionType != DateTimeActionType.TIME) { - datePickerState = if (uiModel.actionType == DateTimeActionType.DATE_TIME && uiModel.inputTextFieldValue?.text?.length == 12 && yearIsInRange(uiModel.inputTextFieldValue.text.substring(0, 8), uiModel.yearRange)) { - rememberDatePickerState( - initialSelectedDateMillis = parseStringDateToMillis( - uiModel.inputTextFieldValue.text.substring(0, uiModel.inputTextFieldValue.text.length - 4), - pattern = uiModel.format, - ), - yearRange = uiModel.yearRange, - ) - } else { - if (uiModel.inputTextFieldValue?.text?.length == 8 && yearIsInRange(uiModel.inputTextFieldValue.text, uiModel.yearRange)) { - rememberDatePickerState( - initialSelectedDateMillis = parseStringDateToMillis(uiModel.inputTextFieldValue.text, uiModel.format), - yearRange = uiModel.yearRange, - ) - } else { - datePickerState - } - } - } + val datePickerState = provideDatePickerState(uiModel) if (showDatePicker) { MaterialTheme( @@ -418,7 +399,7 @@ fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: Suppo ), uiModel.selectableDates, uiModel.format, ) - dateIsInYearRange = yearIsInRange(uiModel.inputTextFieldValue.text.substring(0, uiModel.inputTextFieldValue.text.length - 4), uiModel.yearRange) + dateIsInYearRange = yearIsInRange(uiModel.inputTextFieldValue.text, getDefaultFormat(uiModel.actionType), uiModel.yearRange) isValidHourFormat = isValidHourFormat(uiModel.inputTextFieldValue.text.substring(8, 12)) if (!dateIsInRange || !dateIsInYearRange) supportingTextList.add(dateOutOfRangeItem) if (!isValidHourFormat) supportingTextList.add(incorrectHourFormatItem) @@ -427,7 +408,7 @@ fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: Suppo DateTimeActionType.DATE -> { if (uiModel.inputTextFieldValue?.text!!.length == 8) { dateIsInRange = dateIsInRange(parseStringDateToMillis(uiModel.inputTextFieldValue.text), uiModel.selectableDates, uiModel.format) - dateIsInYearRange = yearIsInRange(uiModel.inputTextFieldValue.text, uiModel.yearRange) + dateIsInYearRange = yearIsInRange(uiModel.inputTextFieldValue.text, getDefaultFormat(uiModel.actionType), uiModel.yearRange) if (!dateIsInRange || !dateIsInYearRange) supportingTextList.add(dateOutOfRangeItem) } } @@ -436,6 +417,31 @@ fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: Suppo return supportingTextList.toList() } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun provideDatePickerState(uiModel: InputDateTimeModel): DatePickerState { + return uiModel.inputTextFieldValue?.text?.takeIf { + it.isNotEmpty() && + yearIsInRange(it, getDefaultFormat(uiModel.actionType), uiModel.yearRange) + }?.let { + rememberDatePickerState( + initialSelectedDateMillis = parseStringDateToMillis( + dateString = it, + pattern = getDefaultFormat(uiModel.actionType), + ), + yearRange = uiModel.yearRange, + ) + } ?: rememberDatePickerState() +} + +private fun getDefaultFormat(actionType: DateTimeActionType): String { + return when (actionType) { + DateTimeActionType.DATE -> "ddMMyyyy" + DateTimeActionType.TIME -> "HHmm" + DateTimeActionType.DATE_TIME -> "ddMMyyyyHHmm" + } +} + enum class DateTimeActionType { DATE, TIME, DATE_TIME } @@ -537,16 +543,12 @@ private fun getTime(timePickerState: TimePickerState, format: String? = "HHmm"): return formater.format(cal.time) } -fun parseStringDateToMillis(dateString: String, pattern: String = "ddMMyyyy", locale: Locale = Locale.getDefault()): Long { - return if (dateString.isNotEmpty()) { - val cal = Calendar.getInstance() - val sdf = SimpleDateFormat(pattern, locale) - sdf.timeZone = TimeZone.getTimeZone("UTC") - cal.time = sdf.parse(dateString) ?: Date() +fun parseStringDateToMillis(dateString: String, pattern: String = "ddMMyyyy"): Long { + val cal = Calendar.getInstance() + return dateString.parseDate(pattern)?.let { + cal.time = it cal.timeInMillis - } else { - 0L - } + } ?: 0L } data class SelectableDates( @@ -597,10 +599,22 @@ internal fun dateIsInRange(date: Long, allowedDates: SelectableDates, format: St ) } -fun yearIsInRange(date: String, yearRange: IntRange): Boolean { - return ( - yearRange.contains(date.substring(date.length - 4, date.length).toInt()) - ) +fun yearIsInRange(date: String, pattern: String, yearRange: IntRange): Boolean { + val cal = Calendar.getInstance() + return date.parseDate(pattern)?.let { + cal.time = it + yearRange.contains(cal.get(Calendar.YEAR)) + } ?: false +} + +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) From 5d10a5759ecaa4f89038cc501919d05689c24902 Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Fri, 5 Jul 2024 09:17:23 +0200 Subject: [PATCH 08/12] fix: [ANDROAPP-6305] Take into account invalid date formats (#270) * fix: [ANDROAPP-6305] Take into account invalid date formats * fix: [ANDROAPP-6305] ktlint --- .../ui/designsystem/component/InputAge.kt | 36 ++++++++++----- .../designsystem/component/InputDateTime.kt | 46 ++++++++----------- .../component/internal/DateTimeUtils.kt | 43 +++++++++++++++++ .../resources/values/strings_en.xml | 1 + 4 files changed, 87 insertions(+), 39 deletions(-) create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtils.kt 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 4aefaff9b..d87902bfb 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 @@ -35,6 +35,8 @@ import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType.None import org.hisp.dhis.mobile.ui.designsystem.component.TimeUnitValues.YEARS import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateTransformation.Companion.DATE_MASK import org.hisp.dhis.mobile.ui.designsystem.component.internal.RegExValidations +import org.hisp.dhis.mobile.ui.designsystem.component.internal.dateIsInRange +import org.hisp.dhis.mobile.ui.designsystem.component.internal.isValidDate 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 @@ -321,19 +323,29 @@ private fun provideSupportingText( ): List? = (uiModel.inputType as? DateOfBirth)?.value?.text?.let { if ( - it.length == DATE_FORMAT.length && - !dateIsInRange(parseStringDateToMillis(it), selectableDates) + it.length == DATE_FORMAT.length && (!isValidDate(it) || !dateIsInRange(parseStringDateToMillis(it), selectableDates)) ) { - val dateOutOfRangeText = "${provideStringResource("date_out_of_range")} (" + - formatStringToDate(selectableDates.initialDate) + " - " + - formatStringToDate(selectableDates.endDate) + ")" - - listOf( - SupportingTextData( - text = dateOutOfRangeText, - SupportingTextState.ERROR, - ), - ).plus(uiModel.supportingText ?: listOf()) + val supportingTextErrorList: MutableList = mutableListOf() + if (!isValidDate(it)) { + val incorrectFormatText = provideStringResource("incorrect_date_format") + supportingTextErrorList.add( + SupportingTextData( + text = incorrectFormatText, + SupportingTextState.ERROR, + ), + ) + } else if (!dateIsInRange(parseStringDateToMillis(it), selectableDates)) { + val dateOutOfRangeText = "${provideStringResource("date_out_of_range")} (" + + formatStringToDate(selectableDates.initialDate) + " - " + + formatStringToDate(selectableDates.endDate) + ")" + supportingTextErrorList.add( + SupportingTextData( + text = dateOutOfRangeText, + SupportingTextState.ERROR, + ), + ) + } + supportingTextErrorList.plus(uiModel.supportingText ?: listOf()).toList() } else { uiModel.supportingText } 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 b4cda5e89..5c5b3004c 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 @@ -54,6 +54,10 @@ 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.isValidHourFormat +import org.hisp.dhis.mobile.ui.designsystem.component.internal.yearIsInRange 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 @@ -90,6 +94,7 @@ fun InputDateTime( var showDatePicker by rememberSaveable { mutableStateOf(false) } var showTimePicker by rememberSaveable { mutableStateOf(false) } var dateOutOfRangeText = uiModel.outOfRangeText ?: provideStringResource("date_out_of_range") + dateOutOfRangeText = "$dateOutOfRangeText (" + formatStringToDate( uiModel.selectableDates.initialDate, ) + " - " + @@ -99,18 +104,22 @@ fun InputDateTime( text = incorrectHourFormatText, SupportingTextState.ERROR, ) + val incorrectDateFormatItem = SupportingTextData( + text = provideStringResource("incorrect_date_format"), + SupportingTextState.ERROR, + ) val dateOutOfRangeItem = SupportingTextData( text = dateOutOfRangeText, SupportingTextState.ERROR, ) val supportingTextList = - getSupportingTextList(uiModel, dateOutOfRangeItem, incorrectHourFormatItem) + getSupportingTextList(uiModel, dateOutOfRangeItem, incorrectHourFormatItem, incorrectDateFormatItem) InputShell( modifier = modifier.testTag("INPUT_DATE_TIME") .focusRequester(focusRequester), title = uiModel.title, - state = if (supportingTextList.contains(dateOutOfRangeItem)) InputShellState.ERROR else uiModel.state, + state = if (supportingTextList.contains(dateOutOfRangeItem) || supportingTextList.contains(incorrectDateFormatItem)) InputShellState.ERROR else uiModel.state, isRequiredField = uiModel.isRequired, onFocusChanged = uiModel.onFocusChanged, inputField = { @@ -228,7 +237,7 @@ fun InputDateTime( SupportingText( item.text, item.state, - modifier = Modifier.testTag("INPUT_DATE_TIME_SUPPORTING_TEXT"), + modifier = Modifier.testTag("INPUT_DATE_TIME_SUPPORTING_TEXT" + item.text), ) } }, @@ -373,7 +382,7 @@ fun InputDateTime( } } -fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: SupportingTextData, incorrectHourFormatItem: SupportingTextData): List { +fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: SupportingTextData, incorrectHourFormatItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData): List { val supportingTextList = mutableListOf() uiModel.supportingText?.forEach { item -> @@ -383,6 +392,8 @@ fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: Suppo val dateIsInRange: Boolean val dateIsInYearRange: Boolean val isValidHourFormat: Boolean + val isValidDateFormat: Boolean + when (uiModel.actionType) { DateTimeActionType.TIME -> { if (uiModel.inputTextFieldValue?.text!!.length == 4) { @@ -401,15 +412,19 @@ fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: Suppo ) 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) } } } @@ -564,14 +579,6 @@ fun formatStringToDate(dateString: String): String { } } -private fun isValidHourFormat(timeString: String): Boolean { - val hourRange = IntRange(0, 24) - val minuteRange = IntRange(0, 60) - - return timeString.length == 4 && hourRange.contains(timeString.substring(0, 2).toInt()) && - minuteRange.contains(timeString.substring(2, 4).toInt()) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun timePickerColors(): TimePickerColors { @@ -592,21 +599,6 @@ private fun timePickerColors(): TimePickerColors { ) } -internal fun dateIsInRange(date: Long, allowedDates: SelectableDates, format: String = "ddMMyyyy"): Boolean { - return ( - date >= parseStringDateToMillis(allowedDates.initialDate, format) && - date <= parseStringDateToMillis(allowedDates.endDate, format) - ) -} - -fun yearIsInRange(date: String, pattern: String, yearRange: IntRange): Boolean { - val cal = Calendar.getInstance() - return date.parseDate(pattern)?.let { - cal.time = it - yearRange.contains(cal.get(Calendar.YEAR)) - } ?: false -} - fun String.parseDate(pattern: String): Date? { return if (isNotEmpty() && length == pattern.length) { val sdf = SimpleDateFormat(pattern, Locale.getDefault()) 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 new file mode 100644 index 000000000..1e2388bde --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtils.kt @@ -0,0 +1,43 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.internal + +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 java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar + +internal fun dateIsInRange(date: Long, allowedDates: SelectableDates, format: String = "ddMMyyyy"): Boolean { + return ( + date >= parseStringDateToMillis(allowedDates.initialDate, format) && + date <= parseStringDateToMillis(allowedDates.endDate, format) + ) +} + +internal fun yearIsInRange(date: String, pattern: String, yearRange: IntRange): Boolean { + val cal = Calendar.getInstance() + return date.parseDate(pattern)?.let { + cal.time = it + yearRange.contains(cal.get(Calendar.YEAR)) + } ?: false +} + +internal fun isValidHourFormat(timeString: String): Boolean { + val hourRange = IntRange(0, 24) + val minuteRange = IntRange(0, 60) + + return timeString.length == 4 && hourRange.contains(timeString.substring(0, 2).toInt()) && + minuteRange.contains(timeString.substring(2, 4).toInt()) +} + +internal fun isValidDate(text: String): Boolean { + if (text.length != 8) return false + val format = SimpleDateFormat("ddMMyyyy") + format.isLenient = false + return try { + format.parse(text) + true + } catch (e: ParseException) { + false + } +} diff --git a/designsystem/src/commonMain/resources/values/strings_en.xml b/designsystem/src/commonMain/resources/values/strings_en.xml index 4696574ef..fcfa8e63c 100644 --- a/designsystem/src/commonMain/resources/values/strings_en.xml +++ b/designsystem/src/commonMain/resources/values/strings_en.xml @@ -39,6 +39,7 @@ Cancel Select date Date out of range + Incorrect date format Incorrect time format Not all options are displayed.\n Search to see more. From 2c24ab25a687cab59d9b6b3385704edc5ddea74f Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Fri, 5 Jul 2024 09:37:20 +0200 Subject: [PATCH 09/12] fix: [ANDROAPP-6124] change phone number input keyboard to phone (#271) --- .../component/InputPhoneNumber.kt | 6 ++-- .../component/InputPhoneNumberTest.kt | 34 +++++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPhoneNumber.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPhoneNumber.kt index 2d246ff82..e93608022 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPhoneNumber.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPhoneNumber.kt @@ -20,6 +20,7 @@ import org.hisp.dhis.mobile.ui.designsystem.component.internal.RegExValidations * @param onCallActionClicked: callback to when call phone number button is clicked. * @param modifier: allows a modifier to be passed externally. * @param maxLength: number of characters/digits that can be entered. + * @param minLength: number of characters/digits that needs be entered to enable phone button. * @param state: Manages the InputShell state. * @param inputStyle: manages the InputShell style. * @param legendData: manages the legendComponent. @@ -42,6 +43,7 @@ fun InputPhoneNumber( onCallActionClicked: () -> Unit, modifier: Modifier = Modifier, maxLength: Int = 12, + minLength: Int = 4, state: InputShellState, inputStyle: InputStyle = InputStyle.DataInputStyle(), legendData: LegendData? = null, @@ -56,7 +58,7 @@ fun InputPhoneNumber( supportingText: List? = emptyList(), allowedCharacters: RegExValidations = RegExValidations.PHONE_NUMBER, ) { - val hasMinimumPhoneNumberInput = inputTextFieldValue?.text.orEmpty().length > 2 + val hasMinimumPhoneNumberInput = inputTextFieldValue?.text.orEmpty().length >= minLength BasicTextInput( title = title, state = state, @@ -75,7 +77,7 @@ fun InputPhoneNumber( }, keyboardOptions = KeyboardOptions( imeAction = imeAction, - keyboardType = KeyboardType.Number, + keyboardType = KeyboardType.Phone, ), allowedCharacters = allowedCharacters.regex, modifier = modifier, diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPhoneNumberTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPhoneNumberTest.kt index 3af2ba0d7..c04191e5f 100644 --- a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPhoneNumberTest.kt +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPhoneNumberTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.text.input.TextFieldValue import org.junit.Rule @@ -47,14 +48,38 @@ class InputPhoneNumberTest { } @Test - fun shouldEnableCallActionButtonAfterInputTextReachesCharacterLimit() { + fun shouldAllowAddAndParenthesisInput() { rule.setContent { var inputValue by remember { mutableStateOf(TextFieldValue()) } InputPhoneNumber( title = "Phone Number", inputTextFieldValue = inputValue, - maxLength = 10, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + onCallActionClicked = { + // no-op + }, + state = InputShellState.UNFOCUSED, + ) + } + rule.onNodeWithTag("INPUT_PHONE_NUMBER_FIELD").assertTextEquals("") + rule.onNodeWithTag("INPUT_PHONE_NUMBER_FIELD").performTextInput("(+91)-9876543210") + rule.onNodeWithTag("INPUT_PHONE_NUMBER_FIELD").assertTextEquals("(+91)-9876543210") + } + + @Test + fun shouldEnableCallActionButtonWhenTextLengthIsEqualOrGreaterThanMinCharacter() { + rule.setContent { + var inputValue by remember { mutableStateOf(TextFieldValue()) } + + InputPhoneNumber( + title = "Phone Number", + inputTextFieldValue = inputValue, + minLength = 10, onValueChanged = { if (it != null) { inputValue = it @@ -67,8 +92,11 @@ class InputPhoneNumberTest { ) } rule.onNodeWithTag("CALL_PHONE_NUMBER_BUTTON").assertIsNotEnabled() - rule.onNodeWithTag("INPUT_PHONE_NUMBER_FIELD").performTextInput("1111111111") + rule.onNodeWithTag("INPUT_PHONE_NUMBER_FIELD").performTextInput("9876543210") rule.onNodeWithTag("CALL_PHONE_NUMBER_BUTTON").assertIsEnabled() + rule.onNodeWithTag("INPUT_PHONE_NUMBER_FIELD").performTextClearance() + rule.onNodeWithTag("INPUT_PHONE_NUMBER_FIELD").performTextInput("987654321") + rule.onNodeWithTag("CALL_PHONE_NUMBER_BUTTON").assertIsNotEnabled() } @Test From c72f70dd3354e3b41bd00aa1a6a39b7edf6feb65 Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Fri, 5 Jul 2024 10:10:21 +0200 Subject: [PATCH 10/12] ci: [ANDROAPP-6305] prepare for release publication (#272) --- .github/workflows/scripts/publish-maven.sh | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scripts/publish-maven.sh b/.github/workflows/scripts/publish-maven.sh index 8d3ba81ac..c50f11238 100755 --- a/.github/workflows/scripts/publish-maven.sh +++ b/.github/workflows/scripts/publish-maven.sh @@ -3,7 +3,7 @@ set -x branch=$(git rev-parse --abbrev-ref HEAD) if [ "$branch" = "main" ]; then - ./gradlew :designsystem:publishToSonatype + ./gradlew :designsystem:publishToSonatype closeAndReleaseSonatypeStagingRepository -PremoveSnapshotSuffix else ./gradlew :designsystem:publishToSonatype fi \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 9db369b56..3fadb9fc0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -version = "0.2-SNAPSHOT" +version = "0.2.1-SNAPSHOT" group = "org.hisp.dhis.mobile" plugins { From c180732a493febb30117ae7dd8a3d316b541c445 Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Thu, 11 Jul 2024 17:41:03 +0530 Subject: [PATCH 11/12] Add a placeholder generate paparazzi golden images workflow (#278) --- .../generate-paparazzi-golden-images.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/generate-paparazzi-golden-images.yml diff --git a/.github/workflows/generate-paparazzi-golden-images.yml b/.github/workflows/generate-paparazzi-golden-images.yml new file mode 100644 index 000000000..d0c53e07c --- /dev/null +++ b/.github/workflows/generate-paparazzi-golden-images.yml @@ -0,0 +1,16 @@ +name: Generate Paparazzi Golden Images + +env: + # The name of the main module repository + main_project_module: designsystem + +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + ci_job: + runs-on: macos-latest + steps: + - name: Run Hello World + run: echo "Hello, World!" From 5f0172dfaa04629eb301a489b9e24cc99943b5c9 Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Tue, 16 Jul 2024 09:19:54 +0200 Subject: [PATCH 12/12] ci: remove continuous-deployment trigger on main, update README.md (#273) --- .github/workflows/continuous-deployment.yml | 1 - README.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/continuous-deployment.yml b/.github/workflows/continuous-deployment.yml index 29d571103..3438728e4 100644 --- a/.github/workflows/continuous-deployment.yml +++ b/.github/workflows/continuous-deployment.yml @@ -7,7 +7,6 @@ on: push: branches: - develop - - main # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/README.md b/README.md index a0b906bea..0b4f996ef 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ In the module **build.gradle.kts**: ```kotlin dependencies { - implementation("org.hisp.dhis.mobile:designsystem:0.2") + implementation("org.hisp.dhis.mobile:designsystem:0.2.1") } ```