From 4e4598cc09e013538cc06e78a570e2fa3d99b71b Mon Sep 17 00:00:00 2001 From: Siddharth Agarwal Date: Tue, 10 Oct 2023 18:53:12 +0530 Subject: [PATCH 01/12] add component (#93) Co-authored-by: Siddharth Agarwal Co-authored-by: Xavier Molloy <44061143+xavimolloy@users.noreply.github.com> --- .../kotlin/org/hisp/dhis/common/App.kt | 2 + .../hisp/dhis/common/screens/Components.kt | 1 + .../dhis/common/screens/InputPolygonScreen.kt | 67 ++++++++ .../ui/designsystem/component/InputPolygon.kt | 133 +++++++++++++++ .../resources/values/strings_en.xml | 2 + .../component/InputPolygonTest.kt | 160 ++++++++++++++++++ 6 files changed, 365 insertions(+) create mode 100644 common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputPolygonScreen.kt create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPolygon.kt create mode 100644 designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPolygonTest.kt diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt index be7c76128..ac0a668c9 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -48,6 +48,7 @@ import org.hisp.dhis.common.screens.InputNumberScreen import org.hisp.dhis.common.screens.InputOrgUnitScreen import org.hisp.dhis.common.screens.InputPercentageScreen import org.hisp.dhis.common.screens.InputPhoneNumberScreen +import org.hisp.dhis.common.screens.InputPolygonScreen import org.hisp.dhis.common.screens.InputPositiveIntegerOrZeroScreen import org.hisp.dhis.common.screens.InputPositiveIntegerScreen import org.hisp.dhis.common.screens.InputQRCodeScreen @@ -171,6 +172,7 @@ fun Main() { Components.INPUT_LINK -> InputLinkScreen() Components.INPUT_EMAIL -> InputEmailScreen() Components.CAROUSEL_BUTTONS -> ButtonCarouselScreen() + Components.INPUT_POLYGON -> InputPolygonScreen() Components.INPUT_ORG_UNIT -> InputOrgUnitScreen() Components.IMAGE_BLOCK -> ImageBlockScreen() } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt index 5a442d8c9..6da7c4ab3 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt @@ -45,6 +45,7 @@ enum class Components(val label: String) { INPUT_LINK("Input Link"), INPUT_EMAIL("Input Email"), CAROUSEL_BUTTONS("Carousel buttons"), + INPUT_POLYGON("Input Polygon"), INPUT_ORG_UNIT("Input Org. Unit"), IMAGE_BLOCK("Image Block"), } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputPolygonScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputPolygonScreen.kt new file mode 100644 index 000000000..4b3e8b380 --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputPolygonScreen.kt @@ -0,0 +1,67 @@ +package org.hisp.dhis.common.screens + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.InputPolygon +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.SubTitle +import org.hisp.dhis.mobile.ui.designsystem.component.Title +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +@Composable +fun InputPolygonScreen() { + ColumnComponentContainer { + Title("Input Polygon", textColor = TextColor.OnSurfaceVariant) + + SubTitle("Basic Input Polygon ", textColor = TextColor.OnSurfaceVariant) + var polygonCaptured by rememberSaveable { mutableStateOf(false) } + InputPolygon( + title = "Label", + polygonAdded = polygonCaptured, + onResetButtonClicked = { + polygonCaptured = false + }, + onUpdateButtonClicked = { + polygonCaptured = true + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + + SubTitle("Disabled Input Polygon without data ", textColor = TextColor.OnSurfaceVariant) + var polygonCaptured1 by rememberSaveable { mutableStateOf(false) } + InputPolygon( + title = "Label", + state = InputShellState.DISABLED, + polygonAdded = polygonCaptured1, + onResetButtonClicked = { + polygonCaptured1 = false + }, + onUpdateButtonClicked = { + polygonCaptured1 = true + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + + SubTitle("Disabled Input Polygon with data ", textColor = TextColor.OnSurfaceVariant) + var polygonCaptured2 by rememberSaveable { mutableStateOf(true) } + InputPolygon( + title = "Label", + state = InputShellState.DISABLED, + polygonAdded = polygonCaptured2, + onResetButtonClicked = { + polygonCaptured2 = false + }, + onUpdateButtonClicked = { + polygonCaptured2 = true + }, + ) + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPolygon.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPolygon.kt new file mode 100644 index 000000000..5962100d8 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPolygon.kt @@ -0,0 +1,133 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddLocationAlt +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.EditLocationAlt +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import org.hisp.dhis.mobile.ui.designsystem.resource.provideStringResource +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +/** + * DHIS2 Input polygon. Wraps DHIS · [InputShell]. + * @param title controls the text to be shown for the title + * @param state Manages the InputShell state + * @param supportingText is a list of SupportingTextData that + * manages all the messages to be shown + * @param legendData manages the legendComponent + * @param polygonText controls the text to be shown for the polygon label + * @param addPolygonBtnText controls the text to be shown for the add polygon button + * @param polygonAdded controls whether the polygon is added or not. + * @param isRequired controls whether the field is mandatory or not + * @param modifier allows a modifier to be passed externally + * @param onResetButtonClicked callback to when reset button is clicked + * @param onUpdateButtonClicked callback to when add button or edit icon is clicked + */ +@Composable +fun InputPolygon( + title: String, + state: InputShellState = InputShellState.UNFOCUSED, + supportingText: List? = null, + legendData: LegendData? = null, + polygonText: String? = provideStringResource("polygon_captured"), + addPolygonBtnText: String = provideStringResource("add_polygon"), + polygonAdded: Boolean = false, + isRequired: Boolean = false, + modifier: Modifier = Modifier, + onResetButtonClicked: () -> Unit, + onUpdateButtonClicked: () -> Unit, +) { + InputShell( + modifier = modifier.testTag("INPUT_POLYGON"), + title = title, + state = state, + isRequiredField = isRequired, + legend = { + legendData?.let { + Legend(legendData, modifier.testTag("INPUT_POLYGON_LEGEND")) + } + }, + supportingText = { + supportingText?.forEach { label -> + SupportingText( + label.text, + label.state, + modifier = modifier.testTag("INPUT_POLYGON_SUPPORTING_TEXT"), + ) + } + }, + inputField = { + if (polygonAdded) { + Text( + text = polygonText!!, + style = MaterialTheme.typography.bodyLarge.copy( + color = if (state != InputShellState.DISABLED) { + TextColor.OnSurface + } else { + TextColor.OnDisabledSurface + }, + ), + ) + } else { + Button( + enabled = state != InputShellState.DISABLED, + ButtonStyle.KEYBOARDKEY, + addPolygonBtnText, + icon = { + Icon( + imageVector = Icons.Outlined.AddLocationAlt, + contentDescription = "Add Polygon Button", + ) + }, + Modifier + .fillMaxWidth() + .padding(end = Spacing.Spacing12) + .testTag("INPUT_POLYGON_ADD_BUTTON"), + ) { + onUpdateButtonClicked.invoke() + } + } + }, + primaryButton = if (polygonAdded && state != InputShellState.DISABLED) { + { + IconButton( + modifier = Modifier.testTag("INPUT_POLYGON_RESET_BUTTON"), + icon = { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = "Reset Button", + ) + }, + onClick = onResetButtonClicked, + ) + } + } else { + null + }, + secondaryButton = if (polygonAdded && state != InputShellState.DISABLED) { + { + SquareIconButton( + modifier = Modifier.testTag("INPUT_POLYGON_EDIT_BUTTON"), + enabled = true, + icon = { + Icon( + imageVector = Icons.Outlined.EditLocationAlt, + contentDescription = "edit_polygon", + ) + }, + onClick = {}, + ) + } + } else { + null + }, + ) +} diff --git a/designsystem/src/commonMain/resources/values/strings_en.xml b/designsystem/src/commonMain/resources/values/strings_en.xml index 85a10f326..1b9cc504f 100644 --- a/designsystem/src/commonMain/resources/values/strings_en.xml +++ b/designsystem/src/commonMain/resources/values/strings_en.xml @@ -20,4 +20,6 @@ Enter phone number QR code Sync + Add polygon + Polygon captured diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPolygonTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPolygonTest.kt new file mode 100644 index 000000000..5ccf16371 --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputPolygonTest.kt @@ -0,0 +1,160 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.junit.Rule +import org.junit.Test + +class InputPolygonTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun shouldDisplayInputPolygonCorrectly() { + rule.setContent { + InputPolygon( + title = "Label", + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_POLYGON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_LEGEND").assertDoesNotExist() + rule.onNodeWithTag("INPUT_POLYGON_SUPPORTING_TEXT").assertDoesNotExist() + } + + @Test + fun shouldAllowAddPolygonWhenEnabled() { + rule.setContent { + InputPolygon( + title = "Label", + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_POLYGON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_ADD_BUTTON").assertIsEnabled() + } + + @Test + fun shouldNotAllowAddPolygonWhenDisabled() { + rule.setContent { + InputPolygon( + title = "Label", + state = InputShellState.DISABLED, + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_POLYGON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_ADD_BUTTON").assertIsNotEnabled() + } + + @Test + fun shouldShowResetAndEditButtonWhenPolygonAdded() { + rule.setContent { + InputPolygon( + title = "Label", + polygonAdded = true, + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_POLYGON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_ADD_BUTTON").assertDoesNotExist() + rule.onNodeWithTag("INPUT_POLYGON_RESET_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_EDIT_BUTTON").assertExists() + } + + @Test + fun shouldHideResetAndEditButtonWhenNoPolygonAdded() { + rule.setContent { + InputPolygon( + title = "Label", + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_POLYGON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_ADD_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_RESET_BUTTON").assertDoesNotExist() + rule.onNodeWithTag("INPUT_POLYGON_EDIT_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldRemovePolygonWhenResetButtonIsClickedAndHideResetAndEditButton() { + rule.setContent { + var polygonAdded by rememberSaveable { mutableStateOf(true) } + + InputPolygon( + title = "Label", + polygonAdded = polygonAdded, + onResetButtonClicked = { + polygonAdded = false + }, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_POLYGON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_RESET_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_RESET_BUTTON").performClick() + + rule.onNodeWithTag("INPUT_POLYGON_ADD_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_RESET_BUTTON").assertDoesNotExist() + rule.onNodeWithTag("INPUT_POLYGON_EDIT_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldHideResetAndEditButtonWhenDisabled() { + rule.setContent { + InputPolygon( + title = "Label", + state = InputShellState.DISABLED, + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_POLYGON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_RESET_BUTTON").assertDoesNotExist() + rule.onNodeWithTag("INPUT_POLYGON_EDIT_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldShowLegendCorrectly() { + rule.setContent { + InputPolygon( + title = "Label", + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_POLYGON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_LEGEND").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_LEGEND").assertHasClickAction() + } + + @Test + fun shouldShowSupportingTextCorrectly() { + rule.setContent { + InputPolygon( + title = "Label", + supportingText = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_POLYGON").assertExists() + rule.onNodeWithTag("INPUT_POLYGON_SUPPORTING_TEXT").assertExists() + } +} From ed8f84be6ecef55c5f1a015f77437207f9eb2327 Mon Sep 17 00:00:00 2001 From: Xavier Molloy <44061143+xavimolloy@users.noreply.github.com> Date: Wed, 11 Oct 2023 12:22:53 +0200 Subject: [PATCH 02/12] ANDROAPP-5579-mobile-ui-Create-InputBarCode-component (#89) * Initial commit * remove parent scroll from Bottom sheet * slight adjustments * final touches * Add simple test * correct issues after design review * Update secondaryButton enabled behavior * Corect enabled logic for action button * rectify header alignment --- .../kotlin/org/hisp/dhis/common/App.kt | 4 +- .../hisp/dhis/common/screens/Components.kt | 1 + .../dhis/common/screens/InputBarCodeScreen.kt | 129 ++++++++++++++++++ .../dhis/common/screens/InputQRCodeScreen.kt | 100 +------------- .../ui/designsystem/component/BottomSheet.kt | 29 ++-- .../ui/designsystem/component/InputBarCode.kt | 72 ++++++++++ .../ui/designsystem/component/InputQRCode.kt | 2 +- .../commonMain/resources/drawable/barcode.xml | 9 ++ .../resources/drawable/barcode_scanner.xml | 9 ++ .../component/InputBarCodeTest.kt | 88 ++++++++++++ 10 files changed, 334 insertions(+), 109 deletions(-) create mode 100644 common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputBarCodeScreen.kt create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputBarCode.kt create mode 100644 designsystem/src/commonMain/resources/drawable/barcode.xml create mode 100644 designsystem/src/commonMain/resources/drawable/barcode_scanner.xml create mode 100644 designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputBarCodeTest.kt diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt index ac0a668c9..7dc7ee59f 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -36,6 +36,7 @@ import org.hisp.dhis.common.screens.FormsComponentsScreen import org.hisp.dhis.common.screens.IconButtonScreen import org.hisp.dhis.common.screens.ImageBlockScreen import org.hisp.dhis.common.screens.InputAgeScreen +import org.hisp.dhis.common.screens.InputBarCodeScreen import org.hisp.dhis.common.screens.InputCheckBoxScreen import org.hisp.dhis.common.screens.InputEmailScreen import org.hisp.dhis.common.screens.InputIntegerScreen @@ -81,7 +82,7 @@ fun App() { @Composable fun Main() { - val currentScreen = remember { mutableStateOf(Components.CAROUSEL_BUTTONS) } + val currentScreen = remember { mutableStateOf(Components.INPUT_BARCODE) } var expanded by remember { mutableStateOf(false) } Column( @@ -171,6 +172,7 @@ fun Main() { Components.INPUT_PHONE_NUMBER -> InputPhoneNumberScreen() Components.INPUT_LINK -> InputLinkScreen() Components.INPUT_EMAIL -> InputEmailScreen() + Components.INPUT_BARCODE -> InputBarCodeScreen() Components.CAROUSEL_BUTTONS -> ButtonCarouselScreen() Components.INPUT_POLYGON -> InputPolygonScreen() Components.INPUT_ORG_UNIT -> InputOrgUnitScreen() diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt index 6da7c4ab3..1b6cb208b 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt @@ -44,6 +44,7 @@ enum class Components(val label: String) { INPUT_PHONE_NUMBER("Input Phone Number"), INPUT_LINK("Input Link"), INPUT_EMAIL("Input Email"), + INPUT_BARCODE("Input Barcode"), CAROUSEL_BUTTONS("Carousel buttons"), INPUT_POLYGON("Input Polygon"), INPUT_ORG_UNIT("Input Org. Unit"), diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputBarCodeScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputBarCodeScreen.kt new file mode 100644 index 000000000..f1632586a --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputBarCodeScreen.kt @@ -0,0 +1,129 @@ +package org.hisp.dhis.common.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import org.hisp.dhis.common.screens.previews.threeButtonCarousel +import org.hisp.dhis.mobile.ui.designsystem.component.BarcodeBlock +import org.hisp.dhis.mobile.ui.designsystem.component.BottomSheetShell +import org.hisp.dhis.mobile.ui.designsystem.component.ButtonCarousel +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.Description +import org.hisp.dhis.mobile.ui.designsystem.component.InputBarCode +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData +import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextState +import org.hisp.dhis.mobile.ui.designsystem.resource.provideStringResource +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +@Composable +fun InputBarCodeScreen() { + ColumnComponentContainer { + var inputValue1 by rememberSaveable { mutableStateOf("889026a1-d01e-4d34-8209-81e8ed5c614b") } + var showEnabledBarCodeBottomSheet by rememberSaveable { mutableStateOf(false) } + + Description("Default Input Barcode", textColor = TextColor.OnSurfaceVariant) + InputBarCode( + "label", + state = InputShellState.UNFOCUSED, + onActionButtonClicked = { + showEnabledBarCodeBottomSheet = !showEnabledBarCodeBottomSheet + }, + inputText = inputValue1, + onValueChanged = { + if (it != null) { + inputValue1 = it + } + }, + ) + + if (showEnabledBarCodeBottomSheet) { + BottomSheetShell( + modifier = Modifier.testTag("LEGEND_BOTTOM_SHEET"), + title = provideStringResource("qr_code"), + icon = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = "Button", + tint = SurfaceColor.Primary, + ) + }, + content = { + Row(horizontalArrangement = Arrangement.Center) { + BarcodeBlock(data = inputValue1) + } + }, + buttonBlock = { + ButtonCarousel( + carouselButtonList = threeButtonCarousel, + ) + }, + ) { + showEnabledBarCodeBottomSheet = false + } + } + Spacer(Modifier.size(Spacing.Spacing18)) + + var inputValue2 by rememberSaveable { mutableStateOf("") } + Description("Required field Input Barcode", textColor = TextColor.OnSurfaceVariant) + InputBarCode( + "label", + state = InputShellState.ERROR, + onActionButtonClicked = { + }, + inputText = inputValue2, + onValueChanged = { + if (it != null) { + inputValue2 = it + } + }, + isRequiredField = true, + supportingText = listOf(SupportingTextData("Required", SupportingTextState.ERROR)), + ) + + Spacer(Modifier.size(Spacing.Spacing18)) + var inputValue by rememberSaveable { mutableStateOf("") } + Description("Disabled Input Barcode", textColor = TextColor.OnSurfaceVariant) + InputBarCode( + "label", + state = InputShellState.DISABLED, + onActionButtonClicked = { + }, + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + ) + + Spacer(Modifier.size(Spacing.Spacing18)) + var inputValue3 by rememberSaveable { mutableStateOf("889026a1-d01e-4d34-8209-81e8ed5c614b") } + Description("Disabled Input Barcode with content", textColor = TextColor.OnSurfaceVariant) + InputBarCode( + "label", + state = InputShellState.DISABLED, + onActionButtonClicked = { + }, + inputText = inputValue3, + onValueChanged = { + if (it != null) { + inputValue3 = it + } + }, + ) + } +} diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputQRCodeScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputQRCodeScreen.kt index 2a73010a3..e1f1c13a6 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputQRCodeScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputQRCodeScreen.kt @@ -1,37 +1,22 @@ package org.hisp.dhis.common.screens -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.QrCodeScanner -import androidx.compose.material.icons.outlined.Share -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow +import org.hisp.dhis.common.screens.previews.threeButtonCarousel import org.hisp.dhis.mobile.ui.designsystem.component.BottomSheetShell +import org.hisp.dhis.mobile.ui.designsystem.component.ButtonCarousel import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer import org.hisp.dhis.mobile.ui.designsystem.component.Description import org.hisp.dhis.mobile.ui.designsystem.component.InputQRCode @@ -40,7 +25,6 @@ import org.hisp.dhis.mobile.ui.designsystem.component.QrCodeBlock import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextState import org.hisp.dhis.mobile.ui.designsystem.resource.provideStringResource -import org.hisp.dhis.mobile.ui.designsystem.theme.Shape import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor @@ -84,83 +68,9 @@ fun InputQRCodeScreen() { } }, buttonBlock = { - Row( - Modifier - .fillMaxSize() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.Center, - ) { - Button( - onClick = {}, - modifier = Modifier - .padding(top = Spacing.Spacing4) - .width(Spacing.Spacing80) - .weight(1f), - shape = Shape.Full, - enabled = true, - colors = ButtonDefaults.buttonColors(Color.Transparent, TextColor.OnSurfaceVariant, Color.Transparent, TextColor.OnDisabledSurface), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.Outlined.Share, - contentDescription = "Carousel Button", - ) - - Spacer(Modifier.size(Spacing.Spacing8)) - Text("Share", style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - } - Button( - onClick = {}, - modifier = Modifier - .padding(top = Spacing.Spacing4) - .width(Spacing.Spacing80) - .weight(1f), - shape = Shape.Full, - enabled = true, - colors = ButtonDefaults.buttonColors(Color.Transparent, TextColor.OnSurfaceVariant, Color.Transparent, TextColor.OnDisabledSurface), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.Outlined.QrCodeScanner, - contentDescription = "Carousel Button", - ) - - Spacer(Modifier.size(Spacing.Spacing8)) - Text("Scan", style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - } - - Button( - onClick = {}, - modifier = Modifier - .padding(top = Spacing.Spacing4) - .width(Spacing.Spacing80) - .weight(1f), - shape = Shape.Full, - enabled = true, - colors = ButtonDefaults.buttonColors(Color.Transparent, TextColor.OnSurfaceVariant, Color.Transparent, TextColor.OnDisabledSurface), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.Outlined.FileDownload, - contentDescription = "Carousel Button", - ) - - Spacer(Modifier.size(Spacing.Spacing8)) - Text("Download", style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - } - } + ButtonCarousel( + carouselButtonList = threeButtonCarousel, + ) }, ) { showEnabledQRBottomSheet = false diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/BottomSheet.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/BottomSheet.kt index 6a99d3d4b..9421a27e5 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/BottomSheet.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/BottomSheet.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding @@ -29,6 +28,8 @@ import kotlinx.coroutines.launch import org.hisp.dhis.mobile.ui.designsystem.theme.InternalSizeValues import org.hisp.dhis.mobile.ui.designsystem.theme.Shape import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing.Spacing24 +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing.Spacing40 import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor @@ -43,7 +44,8 @@ fun BottomSheetHeader( ) { val horizontalAlignment = if (icon != null) Alignment.CenterHorizontally else Alignment.Start Column( - modifier = modifier.padding(horizontal = Spacing.Spacing24, vertical = Spacing.Spacing0), + modifier = modifier.padding(horizontal = Spacing24, vertical = Spacing.Spacing0) + .fillMaxWidth(), horizontalAlignment = horizontalAlignment, ) { icon?. let { @@ -138,10 +140,8 @@ fun BottomSheetShell( Column( modifier = Modifier .background(SurfaceColor.SurfaceBright, Shape.ExtraLargeTop) - .padding(top = Spacing.Spacing24, start = Spacing.Spacing24, end = Spacing.Spacing24, bottom = Spacing.Spacing56) - .heightIn(Spacing.Spacing0, InternalSizeValues.Size800) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, + .padding(top = Spacing24, start = Spacing24, end = Spacing24, bottom = Spacing.Spacing56) + .heightIn(Spacing.Spacing0, InternalSizeValues.Size800), ) { BottomSheetHeader( title, @@ -149,23 +149,23 @@ fun BottomSheetShell( description, icon, modifier = Modifier - .padding(horizontal = Spacing.Spacing24, vertical = Spacing.Spacing0), + .padding(horizontal = Spacing24, vertical = Spacing.Spacing0) + .align(Alignment.CenterHorizontally), ) searchBar?.invoke() Divider( modifier = Modifier.fillMaxWidth() - .padding(top = Spacing.Spacing24), + .padding(top = Spacing24), color = TextColor.OnDisabledSurface, ) Box( modifier = Modifier.align(Alignment.Start) .heightIn(Spacing.Spacing0, InternalSizeValues.Size386) - .padding(bottom = Spacing.Spacing24), + .padding(bottom = Spacing24), ) { Column( modifier = Modifier - .verticalScroll(rememberScrollState()) - .fillMaxHeight(1f), + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, ) { content?.let { @@ -177,7 +177,12 @@ fun BottomSheetShell( } } } - buttonBlock?.invoke() + Box( + Modifier.heightIn(Spacing40, Spacing.Spacing96), + contentAlignment = Alignment.BottomCenter, + ) { + buttonBlock?.invoke() + } } } } diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputBarCode.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputBarCode.kt new file mode 100644 index 000000000..935132100 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputBarCode.kt @@ -0,0 +1,72 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import org.hisp.dhis.mobile.ui.designsystem.resource.provideDHIS2Icon + +/** + * DHIS2 Input QR Code. Wraps DHIS · [BasicTextInput]. + * @param title controls the text to be shown for the title + * @param state Manages the InputShell state + * @param onActionButtonClicked gives access to the action button event + * @param supportingText is a list of SupportingTextData that + * manages all the messages to be shown + * @param legendData manages the legendComponent + * @param inputText manages the value of the text in the input field + * @param isRequiredField controls whether the field is mandatory or not + * @param onNextClicked gives access to the imeAction event + * @param onValueChanged gives access to the onValueChanged event + * @param onFocusChanged gives access to the onFocusChanged returns true if + * item is focused + * @param imeAction controls the imeAction button to be shown + * @param modifier allows a modifier to be passed externally + */ +@Composable +fun InputBarCode( + title: String, + state: InputShellState = InputShellState.UNFOCUSED, + onActionButtonClicked: () -> Unit, + supportingText: List? = null, + legendData: LegendData? = null, + inputText: String? = null, + isRequiredField: Boolean = false, + onNextClicked: (() -> Unit)? = null, + onValueChanged: ((String?) -> Unit)? = null, + onFocusChanged: ((Boolean) -> Unit)? = null, + imeAction: ImeAction = ImeAction.Next, + modifier: Modifier = Modifier, +) { + val actionButtonIconVector = mutableStateOf(if (inputText.isNullOrEmpty()) "barcode_scanner" else "barcode") + BasicTextInput( + title = title, + state = state, + supportingText = supportingText, + legendData = legendData, + inputText = inputText, + isRequiredField = isRequiredField, + onNextClicked = onNextClicked, + onValueChanged = onValueChanged, + keyboardOptions = KeyboardOptions(imeAction = imeAction), + modifier = modifier, + testTag = "BAR_CODE", + onFocusChanged = onFocusChanged, + actionButton = { + SquareIconButton( + modifier = Modifier.testTag("INPUT_BAR_CODE_BUTTON"), + enabled = (state == InputShellState.DISABLED && !inputText.isNullOrEmpty()) || state != InputShellState.DISABLED, + icon = { + Icon( + painter = provideDHIS2Icon(actionButtonIconVector.value), + contentDescription = null, + ) + }, + onClick = onActionButtonClicked, + ) + }, + ) +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputQRCode.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputQRCode.kt index d0e697127..6ea1e78a2 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputQRCode.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputQRCode.kt @@ -60,7 +60,7 @@ fun InputQRCode( actionButton = { SquareIconButton( modifier = Modifier.testTag("INPUT_QR_CODE_BUTTON"), - enabled = true, + enabled = state != InputShellState.DISABLED, icon = { Icon( imageVector = actionButtonIconVector.value, diff --git a/designsystem/src/commonMain/resources/drawable/barcode.xml b/designsystem/src/commonMain/resources/drawable/barcode.xml new file mode 100644 index 000000000..aeef88d64 --- /dev/null +++ b/designsystem/src/commonMain/resources/drawable/barcode.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/designsystem/src/commonMain/resources/drawable/barcode_scanner.xml b/designsystem/src/commonMain/resources/drawable/barcode_scanner.xml new file mode 100644 index 000000000..51d62d37b --- /dev/null +++ b/designsystem/src/commonMain/resources/drawable/barcode_scanner.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputBarCodeTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputBarCodeTest.kt new file mode 100644 index 000000000..e90798cfd --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputBarCodeTest.kt @@ -0,0 +1,88 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsEnabled +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 org.junit.Rule +import org.junit.Test + +class InputBarCodeTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun shouldDisplayComponentCorrectly() { + rule.setContent { + var inputValue by remember { mutableStateOf("") } + + InputBarCode( + title = "Bar code", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + onActionButtonClicked = { + // no-op + }, + ) + } + rule.onNodeWithTag("INPUT_BAR_CODE").assertExists() + } + + @Test + fun shouldDeleteContentWhenResetIsClicked() { + rule.setContent { + var inputValue by remember { mutableStateOf("12345") } + + InputBarCode( + title = "Phone Number", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + onActionButtonClicked = { + // no-op + }, + ) + } + rule.onNodeWithTag("INPUT_BAR_CODE").assertExists() + rule.onNodeWithTag("INPUT_BAR_CODE_RESET_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_BAR_CODE_RESET_BUTTON").performClick() + rule.onNodeWithTag("INPUT_BAR_CODE_FIELD").assertExists() + rule.onNodeWithTag("INPUT_BAR_CODE_FIELD").assertTextEquals("") + } + + @Test + fun shouldShowActionButtonCorrectlyAndBeClickable() { + rule.setContent { + var inputValue by remember { mutableStateOf("12345") } + + InputBarCode( + title = "Phone Number", + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, + onActionButtonClicked = { + // no-op + }, + ) + } + rule.onNodeWithTag("INPUT_BAR_CODE").assertExists() + rule.onNodeWithTag("INPUT_BAR_CODE_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_BAR_CODE_BUTTON").assertIsEnabled() + } +} From 4d4f6e524f1f8868182c62231bac6c22c562106a Mon Sep 17 00:00:00 2001 From: Xavier Molloy <44061143+xavimolloy@users.noreply.github.com> Date: Fri, 13 Oct 2023 09:18:29 +0200 Subject: [PATCH 03/12] Adapt input org unit for implementation (#100) --- .../ui/designsystem/component/InputOrgUnit.kt | 137 +++++++++++++++--- .../component/InputOrgUnitTest.kt | 43 +++--- 2 files changed, 139 insertions(+), 41 deletions(-) diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputOrgUnit.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputOrgUnit.kt index 29e211bcf..e0a6e5793 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputOrgUnit.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputOrgUnit.kt @@ -1,12 +1,28 @@ package org.hisp.dhis.mobile.ui.designsystem.component -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.input.ImeAction import org.hisp.dhis.mobile.ui.designsystem.resource.provideDHIS2Icon +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor /** * DHIS2 Input org unit. Wraps DHIS · [BasicTextInput]. @@ -17,11 +33,9 @@ import org.hisp.dhis.mobile.ui.designsystem.resource.provideDHIS2Icon * @param legendData manages the legendComponent * @param inputText manages the value of the text in the input field * @param isRequiredField controls whether the field is mandatory or not - * @param onNextClicked gives access to the imeAction event * @param onValueChanged gives access to the onValueChanged event * @param onFocusChanged gives access to the onFocusChanged returns true if * item is focused - * @param imeAction controls the imeAction button to be shown * @param modifier allows a modifier to be passed externally * @param onOrgUnitActionCLicked callback to when org unit button is clicked */ @@ -33,27 +47,67 @@ fun InputOrgUnit( legendData: LegendData? = null, inputText: String? = null, isRequiredField: Boolean = false, - onNextClicked: (() -> Unit)? = null, onValueChanged: ((String?) -> Unit)? = null, onFocusChanged: ((Boolean) -> Unit)? = null, - imeAction: ImeAction = ImeAction.Next, modifier: Modifier = Modifier, onOrgUnitActionCLicked: () -> Unit, ) { - BasicTextInput( - title = title, - state = state, - supportingText = supportingText, - legendData = legendData, - inputText = inputText, + val inputValue by remember(inputText) { mutableStateOf(inputText) } + + var deleteButtonIsVisible by remember(inputText) { mutableStateOf(!inputText.isNullOrEmpty() && state != InputShellState.DISABLED) } + val focusRequester = remember { FocusRequester() } + + val primaryButton: + @Composable() + (() -> Unit)? + if (deleteButtonIsVisible) { + primaryButton = { + IconButton( + modifier = Modifier + .testTag("INPUT_ORG_UNIT_RESET_BUTTON") + .padding(Spacing.Spacing0), + icon = { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = "Reset Button", + ) + }, + onClick = { + onValueChanged?.invoke("") + deleteButtonIsVisible = false + focusRequester.requestFocus() + }, + enabled = state != InputShellState.DISABLED, + ) + } + } else { + primaryButton = { + IconButton( + modifier = Modifier + .testTag("INPUT_ORG_UNIT_DROPDOWN_BUTTON") + .padding(Spacing.Spacing0), + icon = { + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = "Dropdown Button", + ) + }, + onClick = { + onOrgUnitActionCLicked.invoke() + focusRequester.requestFocus() + }, + enabled = state != InputShellState.DISABLED, + ) + } + } + InputShell( + modifier = modifier + .testTag("INPUT_ORG_UNIT") + .focusRequester(focusRequester), isRequiredField = isRequiredField, - onNextClicked = onNextClicked, - onValueChanged = onValueChanged, - keyboardOptions = KeyboardOptions(imeAction = imeAction), - modifier = modifier, - testTag = "ORG_UNIT", - onFocusChanged = onFocusChanged, - actionButton = { + title = title, + primaryButton = primaryButton, + secondaryButton = { SquareIconButton( modifier = Modifier.testTag("ORG_UNIT_BUTTON"), enabled = state != InputShellState.DISABLED, @@ -63,8 +117,51 @@ fun InputOrgUnit( contentDescription = "org_unit_icon", ) }, - onClick = onOrgUnitActionCLicked, + onClick = { + onOrgUnitActionCLicked.invoke() + focusRequester.requestFocus() + }, ) }, + state = state, + legend = { + legendData?.let { + Legend(legendData, Modifier.testTag("INPUT_ORG_UNIT_LEGEND")) + } + }, + supportingText = { + supportingText?.forEach { label -> + SupportingText( + label.text, + label.state, + modifier = Modifier.testTag("INPUT_ORG_UNIT_SUPPORTING_TEXT"), + ) + } + }, + inputField = { + Box { + Text( + modifier = Modifier.testTag("INPUT_DROPDOWN_TEXT").fillMaxWidth(), + text = inputValue ?: "", + style = MaterialTheme.typography.bodyLarge.copy( + color = if (state != InputShellState.DISABLED) { + TextColor.OnSurface + } else { + TextColor.OnDisabledSurface + }, + ), + ) + Box( + modifier = Modifier + .matchParentSize() + .alpha(0f) + .clickable(onClick = { + onOrgUnitActionCLicked.invoke() + focusRequester.requestFocus() + }), + ) + } + }, + onFocusChanged = onFocusChanged, ) } diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputOrgUnitTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputOrgUnitTest.kt index eafc4aca5..c88b614a1 100644 --- a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputOrgUnitTest.kt +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputOrgUnitTest.kt @@ -5,16 +5,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextEquals -import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import org.junit.Rule import org.junit.Test @@ -37,7 +34,7 @@ class InputOrgUnitTest { } @Test - fun shouldAllowUserInputWhenEnabled() { + fun shouldDisplayDropdownButtonWhenEmpty() { rule.setContent { var inputValue by rememberSaveable { mutableStateOf("") } InputOrgUnit( @@ -52,42 +49,46 @@ class InputOrgUnitTest { ) } rule.onNodeWithTag("INPUT_ORG_UNIT").assertExists() - rule.onNodeWithTag("INPUT_ORG_UNIT_FIELD").performTextInput("PHC fake") - rule.onNodeWithTag("INPUT_ORG_UNIT_FIELD").assert(hasText("PHC fake")) + rule.onNodeWithTag("ORG_UNIT_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_ORG_UNIT_DROPDOWN_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_ORG_UNIT_DROPDOWN_BUTTON").assertIsEnabled() } @Test - fun shouldNotAllowUserInputWhenDisabled() { + fun shouldDisplayResetButtonWhenNotEmpty() { rule.setContent { + var inputValue by rememberSaveable { mutableStateOf("Sample data") } InputOrgUnit( title = "Label", - state = InputShellState.DISABLED, + inputText = inputValue, + onValueChanged = { + if (it != null) { + inputValue = it + } + }, onOrgUnitActionCLicked = {}, ) } rule.onNodeWithTag("INPUT_ORG_UNIT").assertExists() - rule.onNodeWithTag("INPUT_ORG_UNIT_FIELD").assertIsNotEnabled() + rule.onNodeWithTag("ORG_UNIT_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_ORG_UNIT_RESET_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_ORG_UNIT_RESET_BUTTON").assertIsEnabled() } @Test - fun shouldShowResetButtonWhenTextFieldHasContent() { + fun shouldBeClickableIfDisabled() { rule.setContent { - var inputValue by rememberSaveable { mutableStateOf("") } InputOrgUnit( title = "Label", - inputText = inputValue, - onValueChanged = { - if (it != null) { - inputValue = it - } - }, + state = InputShellState.DISABLED, onOrgUnitActionCLicked = {}, ) } rule.onNodeWithTag("INPUT_ORG_UNIT").assertExists() - rule.onNodeWithTag("INPUT_ORG_UNIT_FIELD").assertExists() - rule.onNodeWithTag("INPUT_ORG_UNIT_FIELD").performTextInput("PHC fake") - rule.onNodeWithTag("INPUT_ORG_UNIT_RESET_BUTTON").assertExists() + rule.onNodeWithTag("ORG_UNIT_BUTTON").assertExists() + rule.onNodeWithTag("ORG_UNIT_BUTTON").assertIsNotEnabled() + rule.onNodeWithTag("INPUT_ORG_UNIT_DROPDOWN_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_ORG_UNIT_DROPDOWN_BUTTON").assertIsNotEnabled() } @Test @@ -109,7 +110,7 @@ class InputOrgUnitTest { rule.onNodeWithTag("INPUT_ORG_UNIT").assertExists() rule.onNodeWithTag("INPUT_ORG_UNIT_RESET_BUTTON").assertExists() rule.onNodeWithTag("INPUT_ORG_UNIT_RESET_BUTTON").performClick() - rule.onNodeWithTag("INPUT_ORG_UNIT_FIELD").assertTextEquals("") + rule.onNodeWithTag("INPUT_DROPDOWN_TEXT").assertTextEquals("") rule.onNodeWithTag("INPUT_ORG_UNIT_RESET_BUTTON").assertDoesNotExist() } From 4d58883f0b74a47d5062019bb7eb795dd52e5966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manu=20Mu=C3=B1oz?= Date: Mon, 16 Oct 2023 07:33:25 +0000 Subject: [PATCH 04/12] fix: [ANDROAPP-5550] scrolling over long text (#102) --- .../mobile/ui/designsystem/component/InputLongText.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputLongText.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputLongText.kt index 0c2d508f8..8c661511c 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputLongText.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputLongText.kt @@ -1,9 +1,6 @@ package org.hisp.dhis.mobile.ui.designsystem.component -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -52,10 +49,7 @@ fun InputLongText( onValueChanged = onValueChanged, keyboardOptions = KeyboardOptions(imeAction = imeAction), isSingleLine = false, - modifier = modifier.scrollable( - orientation = Orientation.Vertical, - state = rememberScrollState(), - ).heightIn(Spacing.Spacing0, InternalSizeValues.Size300), + modifier = modifier.heightIn(Spacing.Spacing0, InternalSizeValues.Size300), testTag = "LONG_TEXT", onFocusChanged = onFocusChanged, ) From a5972836ac9956488b8dfe46702408903716bcd7 Mon Sep 17 00:00:00 2001 From: Xavier Molloy <44061143+xavimolloy@users.noreply.github.com> Date: Mon, 16 Oct 2023 10:34:44 +0200 Subject: [PATCH 05/12] ANDROAPP-5632-mobile-ui-Manage-MissingResourceException-for-dhis2-icons (#103) * Manage missing resources through expect -> actual * modify missing resource check --- .../org/hisp/dhis/common/screens/InputMatrixScreen.kt | 2 +- .../hisp/dhis/common/screens/InputSequentialScreen.kt | 2 +- .../dhis/mobile/ui/designsystem/resource/String.kt | 7 +++++++ .../hisp/dhis/mobile/ui/designsystem/resource/Image.kt | 10 ++++++++-- .../dhis/mobile/ui/designsystem/resource/String.kt | 3 +++ .../dhis/mobile/ui/designsystem/resource/String.kt | 6 +++++- 6 files changed, 25 insertions(+), 5 deletions(-) diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputMatrixScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputMatrixScreen.kt index fde6479f8..32850c3ce 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputMatrixScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputMatrixScreen.kt @@ -22,7 +22,7 @@ fun InputMatrixScreen() { IconCardData( uid = "3eea3133-8a2f-4bba-a259-8b4b457d5ad0", label = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas dolor lacus, aliquam.", - iconRes = "dhis2_microscope_positive", + iconRes = "dhis2_boy_0510y_outline", iconTint = SurfaceColor.Primary, ), IconCardData( diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputSequentialScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputSequentialScreen.kt index 2dffac55b..31ff3883a 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputSequentialScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputSequentialScreen.kt @@ -22,7 +22,7 @@ fun InputSequentialScreen() { IconCardData( uid = "3eea3133-8a2f-4bba-a259-8b4b457d5ad0", label = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas dolor lacus, aliquam.", - iconRes = "dhis2_microscope_positive", + iconRes = "dhis2_boy_0510y_outline", iconTint = SurfaceColor.Primary, ), IconCardData( diff --git a/designsystem/src/androidMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/String.kt b/designsystem/src/androidMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/String.kt index 250e1f022..f83a081af 100644 --- a/designsystem/src/androidMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/String.kt +++ b/designsystem/src/androidMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/String.kt @@ -19,3 +19,10 @@ actual fun provideQuantityStringResource(id: String, quantity: Int): String { } return provideStringResource("${id}_$appendToId").format(quantity) } + +@Composable +actual fun resourceExists(resourceName: String, resourceType: String): Boolean { + val context = LocalContext.current + val resourceId = context.resources.getIdentifier(resourceName, resourceType, context.packageName) + return resourceId != 0 +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/Image.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/Image.kt index 09b545364..355aca501 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/Image.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/Image.kt @@ -11,8 +11,14 @@ import org.jetbrains.compose.resources.painterResource */ @OptIn(ExperimentalResourceApi::class) @Composable -fun provideDHIS2Icon(resourceName: String): Painter = - painterResource("drawable/$resourceName.xml") +fun provideDHIS2Icon(resourceName: String): Painter { + val iconName = if (!resourceExists(resourceName)) { + "dhis2_default_outline" + } else { + resourceName + } + return painterResource("drawable/$iconName.xml") +} @OptIn(ExperimentalResourceApi::class) @Composable diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/String.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/String.kt index 8e0a87f6e..dc4501f4d 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/String.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/String.kt @@ -7,3 +7,6 @@ expect fun provideStringResource(id: String): String @Composable expect fun provideQuantityStringResource(id: String, quantity: Int): String + +@Composable +expect fun resourceExists(resourceName: String, resourceType: String = "drawable"): Boolean diff --git a/designsystem/src/desktopMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/String.kt b/designsystem/src/desktopMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/String.kt index 8f67a931a..923a19e18 100644 --- a/designsystem/src/desktopMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/String.kt +++ b/designsystem/src/desktopMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/resource/String.kt @@ -2,7 +2,6 @@ package org.hisp.dhis.mobile.ui.designsystem.resource import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.res.useResource @@ -42,3 +41,8 @@ private fun getResources(): Map { } return stringsResources } + +@Composable +actual fun resourceExists(resourceName: String, resourceType: String): Boolean { + return false +} From 2b153101b8ffd4169ff9118cfe9fad7cf5baa4cf Mon Sep 17 00:00:00 2001 From: Siddharth Agarwal Date: Mon, 16 Oct 2023 14:30:44 +0530 Subject: [PATCH 06/12] ANDROAPP-5621-mobile-ui-Create-dropdown-component (#99) --- .../kotlin/org/hisp/dhis/common/App.kt | 2 + .../hisp/dhis/common/screens/Components.kt | 1 + .../common/screens/InputDropDownScreen.kt | 105 ++++++++++++ .../designsystem/component/InputDropDown.kt | 138 +++++++++++++++ .../component/InputDropDownTest.kt | 162 ++++++++++++++++++ 5 files changed, 408 insertions(+) create mode 100644 common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputDropDownScreen.kt create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDown.kt create mode 100644 designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDownTest.kt diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt index 7dc7ee59f..f33f62ce9 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -38,6 +38,7 @@ import org.hisp.dhis.common.screens.ImageBlockScreen import org.hisp.dhis.common.screens.InputAgeScreen import org.hisp.dhis.common.screens.InputBarCodeScreen import org.hisp.dhis.common.screens.InputCheckBoxScreen +import org.hisp.dhis.common.screens.InputDropDownScreen import org.hisp.dhis.common.screens.InputEmailScreen import org.hisp.dhis.common.screens.InputIntegerScreen import org.hisp.dhis.common.screens.InputLetterScreen @@ -177,6 +178,7 @@ fun Main() { Components.INPUT_POLYGON -> InputPolygonScreen() Components.INPUT_ORG_UNIT -> InputOrgUnitScreen() Components.IMAGE_BLOCK -> ImageBlockScreen() + Components.INPUT_DROPDOWN -> InputDropDownScreen() } } } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt index 1b6cb208b..fbf5c135d 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt @@ -49,4 +49,5 @@ enum class Components(val label: String) { INPUT_POLYGON("Input Polygon"), INPUT_ORG_UNIT("Input Org. Unit"), IMAGE_BLOCK("Image Block"), + INPUT_DROPDOWN("Input Dropdown"), } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputDropDownScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputDropDownScreen.kt new file mode 100644 index 000000000..af42c36a4 --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputDropDownScreen.kt @@ -0,0 +1,105 @@ +package org.hisp.dhis.common.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.InputDropDown +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.SubTitle +import org.hisp.dhis.mobile.ui.designsystem.component.Title +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +@Composable +fun InputDropDownScreen() { + ColumnComponentContainer { + val options = listOf("Option 1", "Option 2", "Option 3", "Option 4", "Option 5", "Option 6", "Option 7") + var expanded by rememberSaveable { mutableStateOf(false) } + + Title("Input Dropdown", textColor = TextColor.OnSurfaceVariant) + + SubTitle("Basic Input Dropdown ", textColor = TextColor.OnSurfaceVariant) + var selectedItem by rememberSaveable { mutableStateOf(null) } + Box { + InputDropDown( + title = "Label", + state = InputShellState.UNFOCUSED, + selectedItem = selectedItem, + onResetButtonClicked = { + selectedItem = null + }, + onArrowDropDownButtonClicked = { + expanded = !expanded + }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { + DropdownMenuItem( + text = { Text(it) }, + onClick = { + selectedItem = it + expanded = false + }, + ) + } + } + } + Spacer(Modifier.size(Spacing.Spacing18)) + + SubTitle("Basic Input Dropdown with content ", textColor = TextColor.OnSurfaceVariant) + var selectedItem1 by rememberSaveable { mutableStateOf(options[0]) } + InputDropDown( + title = "Label", + state = InputShellState.UNFOCUSED, + selectedItem = selectedItem1, + onResetButtonClicked = { + selectedItem1 = null + }, + onArrowDropDownButtonClicked = { + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + + SubTitle("Error Input Dropdown ", textColor = TextColor.OnSurfaceVariant) + var selectedItem2 by rememberSaveable { mutableStateOf(null) } + InputDropDown( + title = "Label", + state = InputShellState.ERROR, + selectedItem = selectedItem2, + onResetButtonClicked = { + selectedItem2 = null + }, + onArrowDropDownButtonClicked = { + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + + SubTitle("Disabled Input Dropdown with content ", textColor = TextColor.OnSurfaceVariant) + var selectedItem3 by rememberSaveable { mutableStateOf(options[1]) } + InputDropDown( + title = "Label", + state = InputShellState.DISABLED, + selectedItem = selectedItem3, + onResetButtonClicked = { + selectedItem3 = null + }, + onArrowDropDownButtonClicked = { + expanded = !expanded + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDown.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDown.kt new file mode 100644 index 000000000..417c2c356 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDown.kt @@ -0,0 +1,138 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.testTag +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +/** + * DHIS2 Input dropdown. Wraps DHIS · [InputShell]. + * @param title controls the text to be shown for the title + * @param state Manages the InputShell state + * @param selectedItem manages the value of the selected item + * @param supportingTextData is a list of SupportingTextData that + * manages all the messages to be shown + * @param legendData manages the legendComponent + * @param isRequiredField controls whether the field is mandatory or not + * @param onFocusChanged gives access to the onFocusChanged returns true if + * item is focused + * @param modifier allows a modifier to be passed externally + * @param onResetButtonClicked callback to when reset button is clicked + * @param onArrowDropDownButtonClicked callback to when arrow drop down button is clicked + */ +@Composable +fun InputDropDown( + title: String, + state: InputShellState, + selectedItem: String? = null, + supportingTextData: List? = null, + legendData: LegendData? = null, + isRequiredField: Boolean = false, + modifier: Modifier = Modifier, + onFocusChanged: ((Boolean) -> Unit)? = null, + onResetButtonClicked: () -> Unit, + onArrowDropDownButtonClicked: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + InputShell( + modifier = modifier + .testTag("INPUT_DROPDOWN") + .focusRequester(focusRequester), + title = title, + state = state, + isRequiredField = isRequiredField, + onFocusChanged = onFocusChanged, + supportingText = { + supportingTextData?.forEach { label -> + SupportingText( + label.text, + label.state, + modifier = modifier.testTag("INPUT_DROPDOWN_SUPPORTING_TEXT"), + ) + } + }, + legend = { + legendData?.let { + Legend(legendData, modifier.testTag("INPUT_DROPDOWN_LEGEND")) + } + }, + inputField = { + Box { + Text( + modifier = Modifier + .testTag("INPUT_DROPDOWN_TEXT") + .fillMaxWidth(), + text = selectedItem ?: "", + style = MaterialTheme.typography.bodyLarge.copy( + color = if (state != InputShellState.DISABLED) { + TextColor.OnSurface + } else { + TextColor.OnDisabledSurface + }, + ), + ) + Box( + modifier = Modifier + .matchParentSize() + .alpha(0f) + .clickable( + enabled = state != InputShellState.DISABLED, + onClick = { + focusRequester.requestFocus() + onArrowDropDownButtonClicked.invoke() + }, + ), + ) + } + }, + primaryButton = { + IconButton( + modifier = Modifier.testTag("INPUT_DROPDOWN_ARROW_BUTTON").onFocusChanged { + onFocusChanged?.invoke(it.isFocused) + }, + enabled = state != InputShellState.DISABLED, + icon = { + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = "Dropdown Button", + ) + }, + onClick = { + focusRequester.requestFocus() + onArrowDropDownButtonClicked.invoke() + }, + ) + }, + secondaryButton = + if (!selectedItem.isNullOrEmpty() && state != InputShellState.DISABLED) { + { + IconButton( + modifier = Modifier.testTag("INPUT_DROPDOWN_RESET_BUTTON"), + icon = { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = "Reset Button", + ) + }, + onClick = onResetButtonClicked, + ) + } + } else { + null + }, + ) +} diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDownTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDownTest.kt new file mode 100644 index 000000000..720f80021 --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDownTest.kt @@ -0,0 +1,162 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +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 org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.junit.Rule +import org.junit.Test + +class InputDropDownTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun shouldDisplayInputDropDownCorrectly() { + rule.setContent { + InputDropDown( + title = "Label", + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_LEGEND").assertDoesNotExist() + rule.onNodeWithTag("INPUT_DROPDOWN_SUPPORTING_TEXT").assertDoesNotExist() + } + + @Test + fun shouldAllowDropDownSelectionWhenEnabled() { + rule.setContent { + InputDropDown( + title = "Label", + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_ARROW_BUTTON").assertIsEnabled() + rule.onNodeWithTag("INPUT_DROPDOWN_ARROW_BUTTON") + } + + @Test + fun shouldNotAllowDropDownSelectionWhenDisabled() { + rule.setContent { + InputDropDown( + title = "Label", + state = InputShellState.DISABLED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_ARROW_BUTTON").assertIsNotEnabled() + } + + @Test + fun shouldShowResetButtonWhenItemIsSelected() { + rule.setContent { + InputDropDown( + title = "Label", + selectedItem = "Input", + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_RESET_BUTTON").assertExists() + } + + @Test + fun shouldHideResetButtonWhenNoItemIsSelected() { + rule.setContent { + InputDropDown( + title = "Label", + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_RESET_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldHideResetButtonWhenDisabled() { + rule.setContent { + InputDropDown( + title = "Label", + selectedItem = "Option 1", + state = InputShellState.DISABLED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_RESET_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldRemoveSelectedItemWhenResetButtonIsClickedAndHideResetButton() { + rule.setContent { + var itemSelected by rememberSaveable { mutableStateOf("Option 1") } + + InputDropDown( + title = "Label", + selectedItem = itemSelected, + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = { + itemSelected = null + }, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_RESET_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_RESET_BUTTON").performClick() + rule.onNodeWithTag("INPUT_DROPDOWN_TEXT").assertTextEquals("") + rule.onNodeWithTag("INPUT_DROPDOWN_RESET_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldShowLegendCorrectly() { + rule.setContent { + InputDropDown( + title = "Label", + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_LEGEND").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_LEGEND").assertHasClickAction() + } + + @Test + fun shouldShowSupportingTextCorrectly() { + rule.setContent { + InputDropDown( + title = "Label", + supportingTextData = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_SUPPORTING_TEXT").assertExists() + } +} From 15c2833aded012e59c43342fca541a9ed2edfbf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Miguel=20Rubio?= Date: Mon, 16 Oct 2023 12:34:02 +0200 Subject: [PATCH 07/12] Update compose to 1.5.10-rc01 (#98) --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index ecf9dfef4..8a4d5639c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,5 +22,5 @@ android.minSdk=21 #Versions kotlin.version=1.9.10 -agp.version=8.1.1 -compose.version=1.5.10-beta01 \ No newline at end of file +agp.version=8.1.2 +compose.version=1.5.10-rc01 \ No newline at end of file From 671506d684e6a86e59c8998359e75f8aa9f4b358 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Mon, 16 Oct 2023 17:20:50 +0530 Subject: [PATCH 08/12] ANDROAPP-5573-mobile-ui-Create-InputDate-component (#105) * Add support for passing visual transformation in `BasicTextField` * Rename `DateOfBirthTransformation` to `DateTransformation` * Move `DATE_MASK` inside `DateTransformation` * Rename `DATE_OF_BIRTH` regex to `DATE_TIME` fixup! a73dafd80fb5810cadea87a3893b652f4338165c * Add time transformation * Make date and time transformation classes public * Add a common interface for date time visual transformations * Add date time transformation * Add input date time component * Rename `ActionIconType` to `DateTimeActionIconType` * Update date time transformation mask * Run code format * Add legend data as param to `InputDateTime` * Display input reset button if the input value is not null or blank * Fix broken test * Run code formatting --- .../kotlin/org/hisp/dhis/common/App.kt | 2 + .../hisp/dhis/common/screens/Components.kt | 1 + .../common/screens/InputDateTimeScreen.kt | 81 +++++++++ .../ui/designsystem/component/InputAge.kt | 12 +- .../designsystem/component/InputDateTime.kt | 136 +++++++++++++++ .../ui/designsystem/component/InputField.kt | 18 +- .../component/internal/StringUtils.kt | 160 +++++++++++++++++- .../component/InputDateTimeTest.kt | 76 +++++++++ .../internal/DateTimeTransformationTest.kt | 39 +++++ ...ationTest.kt => DateTransformationTest.kt} | 12 +- .../internal/TimeTransformationTest.kt | 39 +++++ 11 files changed, 550 insertions(+), 26 deletions(-) create mode 100644 common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputDateTimeScreen.kt create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTime.kt create mode 100644 designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTimeTest.kt create mode 100644 designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeTransformationTest.kt rename designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/{DateOfBirthTransformationTest.kt => DateTransformationTest.kt} (68%) create mode 100644 designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/TimeTransformationTest.kt diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt index f33f62ce9..8b7beef5f 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -38,6 +38,7 @@ import org.hisp.dhis.common.screens.ImageBlockScreen import org.hisp.dhis.common.screens.InputAgeScreen import org.hisp.dhis.common.screens.InputBarCodeScreen import org.hisp.dhis.common.screens.InputCheckBoxScreen +import org.hisp.dhis.common.screens.InputDateTimeScreen import org.hisp.dhis.common.screens.InputDropDownScreen import org.hisp.dhis.common.screens.InputEmailScreen import org.hisp.dhis.common.screens.InputIntegerScreen @@ -179,6 +180,7 @@ fun Main() { Components.INPUT_ORG_UNIT -> InputOrgUnitScreen() Components.IMAGE_BLOCK -> ImageBlockScreen() Components.INPUT_DROPDOWN -> InputDropDownScreen() + Components.INPUT_DATE_TIME -> InputDateTimeScreen() } } } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt index fbf5c135d..7b63e6516 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt @@ -50,4 +50,5 @@ enum class Components(val label: String) { INPUT_ORG_UNIT("Input Org. Unit"), IMAGE_BLOCK("Image Block"), INPUT_DROPDOWN("Input Dropdown"), + INPUT_DATE_TIME("Input Date Time"), } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputDateTimeScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputDateTimeScreen.kt new file mode 100644 index 000000000..1f212f412 --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputDateTimeScreen.kt @@ -0,0 +1,81 @@ +package org.hisp.dhis.common.screens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.DateTimeActionIconType +import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTime +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +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 + +@Composable +fun InputDateTimeScreen() { + ColumnComponentContainer { + var date by remember { mutableStateOf("") } + var time by remember { mutableStateOf("") } + var dateTime by remember { mutableStateOf("") } + + InputDateTime( + title = "Label", + value = date, + visualTransformation = DateTransformation(), + actionIconType = DateTimeActionIconType.DATE, + onActionClicked = { + // no-op + }, + onValueChanged = { date = it }, + ) + + InputDateTime( + title = "Label", + value = time, + visualTransformation = TimeTransformation(), + actionIconType = DateTimeActionIconType.TIME, + onActionClicked = { + // no-op + }, + onValueChanged = { time = it }, + ) + + InputDateTime( + title = "Label", + value = dateTime, + visualTransformation = DateTimeTransformation(), + actionIconType = DateTimeActionIconType.DATE_TIME, + onActionClicked = { + // no-op + }, + onValueChanged = { dateTime = it }, + ) + + InputDateTime( + title = "Label", + value = "", + state = InputShellState.DISABLED, + onActionClicked = { + // no-op + }, + onValueChanged = { + // no-op + }, + ) + + InputDateTime( + title = "Label", + value = "", + isRequired = true, + state = InputShellState.ERROR, + onActionClicked = { + // no-op + }, + onValueChanged = { + // no-op + }, + ) + } +} 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 15dab7ab8..2bba32129 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 @@ -18,14 +18,11 @@ import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType.Age import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType.DateOfBirth 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.resource.provideStringResource import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing -// Update [DateOfBirthTransformation] when updating the mask -// Check the usages before modifying -private const val DATE_OF_BIRTH_MASK = "DDMMYYYY" - /** * Input filed to enter date-of-birth or age * @@ -57,12 +54,11 @@ fun InputAge( onValueChanged: (AgeInputType) -> Unit, ) { val maxAgeCharLimit = 3 - val allowedCharacters = RegExValidations.DATE_OF_BIRTH.regex + val allowedCharacters = RegExValidations.DATE_TIME.regex val helperText = remember(inputType) { when (inputType) { - None -> null - is DateOfBirth -> DATE_OF_BIRTH_MASK + None, is DateOfBirth -> null is Age -> inputType.unit.value } } @@ -202,7 +198,7 @@ private fun transformInputText(inputType: AgeInputType): String { } private fun updateDateOfBirth(inputType: DateOfBirth, newText: String): AgeInputType { - return if (newText.length <= DATE_OF_BIRTH_MASK.length) { + return if (newText.length <= DATE_MASK.length) { inputType.copy(value = newText) } else { inputType 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 new file mode 100644 index 000000000..c52553119 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTime.kt @@ -0,0 +1,136 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Event +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import org.hisp.dhis.mobile.ui.designsystem.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.theme.Spacing + +/** + * Input field to enter date, time or date&time. It will format content based on given visual + * transformation + * + * @param title: Label of the component. + * @param value: Input of the component in the format of DDMMYYYY/HHMM/DDMMYYYYHHMM + * @param actionIconType: Type of action icon to display. [DateTimeActionIconType.DATE_TIME], [DateTimeActionIconType.DATE], [DateTimeActionIconType.TIME] + * @param onActionClicked: Callback to handle the action when the calendar icon is clicked. + * @param state: [InputShellState] + * @param legendData: [LegendData] + * @param supportingText: List of [SupportingTextData] that manages all the messages to be shown. + * @param isRequired: Mark this input as marked + * @param visualTransformation: Pass a visual transformation to format the date input visually. By default uses [DateTransformation] + * @param onValueChanged: Callback to receive changes in the input in the format of DDMMYYYY/HHMM/DDMMYYYYHHMM + */ +@Composable +fun InputDateTime( + title: String, + value: String?, + actionIconType: DateTimeActionIconType = DateTimeActionIconType.DATE_TIME, + onActionClicked: () -> Unit, + modifier: Modifier = Modifier, + state: InputShellState = InputShellState.UNFOCUSED, + legendData: LegendData? = null, + supportingText: List? = null, + isRequired: Boolean = false, + imeAction: ImeAction = ImeAction.Next, + visualTransformation: DateTimeVisualTransformation = DateTransformation(), + onFocusChanged: ((Boolean) -> Unit) = {}, + onValueChanged: (String) -> Unit, +) { + val allowedCharacters = RegExValidations.DATE_TIME.regex + + InputShell( + modifier = modifier.testTag("INPUT_DATE_TIME"), + title = title, + state = state, + isRequiredField = isRequired, + onFocusChanged = onFocusChanged, + inputField = { + BasicTextField( + modifier = Modifier + .testTag("INPUT_DATE_TIME_TEXT_FIELD") + .fillMaxWidth(), + inputText = value.orEmpty(), + isSingleLine = true, + onInputChanged = { newText -> + if (newText.length > visualTransformation.maskLength) { + return@BasicTextField + } + + if (allowedCharacters.containsMatchIn(newText) || newText.isBlank()) { + onValueChanged.invoke(newText) + } + }, + enabled = state != InputShellState.DISABLED, + state = state, + keyboardOptions = KeyboardOptions(imeAction = imeAction, keyboardType = KeyboardType.Number), + visualTransformation = visualTransformation, + ) + }, + primaryButton = { + if (!value.isNullOrBlank() && state != 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("") + }, + ) + } + }, + secondaryButton = { + val icon = when (actionIconType) { + DateTimeActionIconType.DATE, DateTimeActionIconType.DATE_TIME -> Icons.Filled.Event + DateTimeActionIconType.TIME -> Icons.Filled.Schedule + } + + SquareIconButton( + modifier = Modifier.testTag("INPUT_DATE_TIME_ACTION_BUTTON") + .focusable(), + icon = { + Icon( + imageVector = icon, + contentDescription = null, + ) + }, + onClick = onActionClicked, + enabled = state != InputShellState.DISABLED, + ) + }, + supportingText = { + supportingText?.forEach { label -> + SupportingText( + label.text, + label.state, + ) + } + }, + legend = { + legendData?.let { + Legend(legendData, Modifier.testTag("INPUT_DATE_TIME_LEGEND")) + } + }, + ) +} + +enum class DateTimeActionIconType { + DATE, TIME, DATE_TIME +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputField.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputField.kt index c575a1bd6..a133ea902 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputField.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputField.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.VisualTransformation -import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateOfBirthTransformation +import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateTransformation import org.hisp.dhis.mobile.ui.designsystem.component.internal.PrefixTransformation import org.hisp.dhis.mobile.ui.designsystem.component.internal.SuffixTransformer import org.hisp.dhis.mobile.ui.designsystem.theme.Color.Blue300 @@ -72,6 +72,9 @@ fun EmptyInput( * @param modifier to pass a modifier if necessary * @param state manages the color of cursor depending on the state of parent component * @param keyboardOptions manages the ImeAction to be shown on the keyboard + * @param visualTransformation manages custom visual transformation. When null is passed it + * will use the visual transformation created based on helper style, when a visual transformation + * is passed it will ignore the helper style. * @param onNextClicked gives access to the ImeAction event */ @OptIn(ExperimentalComposeUiApi::class) @@ -86,27 +89,30 @@ fun BasicTextField( modifier: Modifier = Modifier, state: InputShellState = InputShellState.FOCUSED, keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + visualTransformation: VisualTransformation? = null, onNextClicked: (() -> Unit)? = null, ) { val keyboardController = LocalSoftwareKeyboardController.current - var visualTransformation = VisualTransformation.None + var textFieldVisualTransformation = VisualTransformation.None if (helperStyle != InputStyle.NONE) { when (helperStyle) { InputStyle.WITH_HELPER_BEFORE -> { - helper?.let { visualTransformation = PrefixTransformation(it, enabled) } + helper?.let { textFieldVisualTransformation = PrefixTransformation(it, enabled) } } InputStyle.WITH_DATE_OF_BIRTH_HELPER -> { - helper?.let { visualTransformation = DateOfBirthTransformation(it) } + textFieldVisualTransformation = DateTransformation() } else -> { helper?.let { - visualTransformation = SuffixTransformer(it) + textFieldVisualTransformation = SuffixTransformer(it) } } } } + textFieldVisualTransformation = visualTransformation ?: textFieldVisualTransformation + val cursorColor by remember { if (state == InputShellState.UNFOCUSED || state == InputShellState.FOCUSED) { mutableStateOf(InputShellState.FOCUSED.color) @@ -151,7 +157,7 @@ fun BasicTextField( keyboardController?.hide() }, ), - visualTransformation = visualTransformation, + visualTransformation = textFieldVisualTransformation, cursorBrush = SolidColor(cursorColor), ) } 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 8e803781d..adee4f02b 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 @@ -63,23 +63,33 @@ internal class SuffixTransformer(val suffix: String) : VisualTransformation { } } -internal class DateOfBirthTransformation(private val mask: String) : VisualTransformation { +interface DateTimeVisualTransformation : VisualTransformation { + val maskLength: Int +} + +class DateTransformation : DateTimeVisualTransformation { companion object { private const val SEPARATOR = "/" + + // Check the usages before modifying + internal const val DATE_MASK = "DDMMYYYY" } + override val maskLength: Int + get() = DATE_MASK.length + override fun filter(text: AnnotatedString): TransformedText { return dateFilter(text) } private fun dateFilter(text: AnnotatedString): TransformedText { - val trimmed = if (text.text.length > mask.length) text.text.substring(0..mask.length) else text.text + val trimmed = if (text.text.length > DATE_MASK.length) text.text.substring(0..DATE_MASK.length) else text.text val output = buildAnnotatedString { - for (i in mask.indices) { + for (i in DATE_MASK.indices) { val dateChar = trimmed.getOrNull(i) if (dateChar == null) { - append(AnnotatedString(mask[i].toString(), DHIS2SCustomTextStyles.inputFieldHelper)) + append(AnnotatedString(DATE_MASK[i].toString(), DHIS2SCustomTextStyles.inputFieldHelper)) } else { append(trimmed[i]) } @@ -117,6 +127,146 @@ internal class DateOfBirthTransformation(private val mask: String) : VisualTrans } } +class TimeTransformation : DateTimeVisualTransformation { + + companion object { + private const val SEPARATOR = ":" + + // Check the usages before modifying + internal const val TIME_MASK = "HHMM" + } + + override val maskLength: Int + get() = TIME_MASK.length + + override fun filter(text: AnnotatedString): TransformedText { + return timeFilter(text) + } + + private fun timeFilter(text: AnnotatedString): TransformedText { + val trimmed = if (text.text.length > TIME_MASK.length) text.text.substring(0..TIME_MASK.length) else text.text + val output = buildAnnotatedString { + for (i in TIME_MASK.indices) { + val timeChar = trimmed.getOrNull(i) + if (timeChar == null) { + append(AnnotatedString(TIME_MASK[i].toString(), DHIS2SCustomTextStyles.inputFieldHelper)) + } else { + append(trimmed[i]) + } + + if (i == 1) { + val separator = if (timeChar != null) { + SEPARATOR + } else { + AnnotatedString(SEPARATOR, DHIS2SCustomTextStyles.inputFieldHelper) + } + append(separator) + } + } + } + + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (trimmed.lastIndex >= 0) { + if (offset <= 1) return offset + return offset + 1 + } else { + return 0 + } + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset > text.length) return text.length + return offset + } + } + + return TransformedText(output, offsetMapping) + } +} + +class DateTimeTransformation : DateTimeVisualTransformation { + + companion object { + private const val SEPARATOR_DATE = "/" + private const val SEPARATOR_TIME = ":" + private const val SEPARATOR_DATE_TIME = " - " + + // Check the usages before modifying + internal const val DATETIME_MASK = "DDMMYYYYhhmm" + } + + override val maskLength: Int + get() = DATETIME_MASK.length + + override fun filter(text: AnnotatedString): TransformedText { + return dateTimeFilter(text) + } + + private fun dateTimeFilter(text: AnnotatedString): TransformedText { + val trimmed = if (text.text.length > DATETIME_MASK.length) text.text.substring(0..DATETIME_MASK.length) else text.text + val output = buildAnnotatedString { + for (i in DATETIME_MASK.indices) { + val char = trimmed.getOrNull(i) + if (char == null) { + append(AnnotatedString(DATETIME_MASK[i].toString(), DHIS2SCustomTextStyles.inputFieldHelper)) + } else { + append(trimmed[i]) + } + + when (i) { + 1, 3 -> { + val separator = if (char == null) { + AnnotatedString(SEPARATOR_DATE, DHIS2SCustomTextStyles.inputFieldHelper) + } else { + SEPARATOR_DATE + } + append(separator) + } + 7 -> { + val separator = if (char == null) { + AnnotatedString(SEPARATOR_DATE_TIME, DHIS2SCustomTextStyles.inputFieldHelper) + } else { + SEPARATOR_DATE_TIME + } + append(separator) + } + 9 -> { + val separator = if (char == null) { + AnnotatedString(SEPARATOR_TIME, DHIS2SCustomTextStyles.inputFieldHelper) + } else { + SEPARATOR_TIME + } + append(separator) + } + } + } + } + + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + return if (trimmed.lastIndex >= 0) { + if (offset <= 1) return offset + if (offset <= 3) return offset + 1 + if (offset < 8) return offset + 2 + if (offset == 8) return offset + 5 + if (offset <= 11) return offset + 6 + return 18 + } else { + 0 + } + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset > text.length) return text.length + return offset + } + } + + return TransformedText(output, offsetMapping) + } +} + enum class RegExValidations(val regex: Regex) { BRITISH_DECIMAL_NOTATION("""^(?!\.)(?!.*-[^0-9])(?!(?:[^.]*\.){3})[-0-9]*(?:\.[0-9]*)?$""".toRegex()), EUROPEAN_DECIMAL_NOTATION("""^(?!.*,.+,|.*-.*-)[0-9,-]*$""".toRegex()), @@ -129,5 +279,5 @@ enum class RegExValidations(val regex: Regex) { PHONE_NUMBER("^[+0-9-()]+$".toRegex()), LINK("((https?|ftp|smtp)://)?(www\\.)?[a-zA-Z0-9@:%._+~#=-]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_+.~#?&/=-]*)".toRegex()), EMAIL("^[a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}\$".toRegex()), - DATE_OF_BIRTH("^[0-9]+$".toRegex()), + DATE_TIME("^[0-9]+$".toRegex()), } 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 new file mode 100644 index 000000000..00e1fd20f --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTimeTest.kt @@ -0,0 +1,76 @@ +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTime +import org.junit.Rule +import org.junit.Test + +class InputDateTimeTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun dateTimeFieldChangesShouldWorkCorrectly() { + var input by mutableStateOf("") + rule.setContent { + InputDateTime( + title = "Label", + value = null, + onActionClicked = { + // no-op + }, + ) { + input = it + } + } + + rule.onNodeWithTag("INPUT_DATE_TIME_TEXT_FIELD").performTextInput("1002") + + assert(input == "1002") + } + + @Test + fun resetButtonShouldNotBeShownWhenTextIsEmpty() { + var input by mutableStateOf("") + + rule.setContent { + InputDateTime( + title = "Label", + value = null, + onActionClicked = { + // no-op + }, + ) { + input = it + } + } + + rule.onNodeWithTag("INPUT_DATE_TIME_RESET_BUTTON").assertDoesNotExist() + } + + @Test + fun clickingOnResetButtonShouldClearInput() { + var input by mutableStateOf("1002") + + rule.setContent { + InputDateTime( + title = "Label", + value = input, + onActionClicked = { + // no-op + }, + ) { + input = it + } + } + + rule.onNodeWithTag("INPUT_DATE_TIME_RESET_BUTTON").performClick() + + assert(input.isBlank()) + } +} diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeTransformationTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeTransformationTest.kt new file mode 100644 index 000000000..9af3aad32 --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeTransformationTest.kt @@ -0,0 +1,39 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.internal + +import androidx.compose.ui.text.AnnotatedString +import org.junit.Test + +class DateTimeTransformationTest { + + private val transformation = DateTimeTransformation() + + @Test + fun timeTransformationShouldWorkCorrectly() { + val transformedText = transformation + .filter(AnnotatedString("100219941240")) + .text + .toString() + + assert(transformedText == "10/02/1994 - 12:40") + } + + @Test + fun partialTimeTransformationShouldWorkCorrectly() { + val transformedText = transformation + .filter(AnnotatedString("100219")) + .text + .toString() + + assert(transformedText == "10/02/19YY - hh:mm") + } + + @Test + fun emptyTextShouldDisplayDateMask() { + val transformedText = transformation + .filter(AnnotatedString("")) + .text + .toString() + + assert(transformedText == "DD/MM/YYYY - hh:mm") + } +} diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateOfBirthTransformationTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTransformationTest.kt similarity index 68% rename from designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateOfBirthTransformationTest.kt rename to designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTransformationTest.kt index eb0844d3d..4c606285f 100644 --- a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateOfBirthTransformationTest.kt +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTransformationTest.kt @@ -3,14 +3,12 @@ package org.hisp.dhis.mobile.ui.designsystem.component.internal import androidx.compose.ui.text.AnnotatedString import org.junit.Test -private const val DATE_OF_BIRTH_MASK = "DDMMYYYY" +class DateTransformationTest { -class DateOfBirthTransformationTest { - - private val transformation = DateOfBirthTransformation(DATE_OF_BIRTH_MASK) + private val transformation = DateTransformation() @Test - fun dateOfBirthTransformationShouldWorkCorrectly() { + fun dateTransformationShouldWorkCorrectly() { val transformedText = transformation .filter(AnnotatedString("10041985")) .text @@ -20,7 +18,7 @@ class DateOfBirthTransformationTest { } @Test - fun partialDateOfBirthTransformationShouldWorkCorrectly() { + fun partialDateTransformationShouldWorkCorrectly() { val transformedText = transformation .filter(AnnotatedString("100")) .text @@ -30,7 +28,7 @@ class DateOfBirthTransformationTest { } @Test - fun emptyTextShouldDisplayDateOfBirthMask() { + fun emptyTextShouldDisplayDateMask() { val transformedText = transformation .filter(AnnotatedString("")) .text diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/TimeTransformationTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/TimeTransformationTest.kt new file mode 100644 index 000000000..b968d9e0f --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/TimeTransformationTest.kt @@ -0,0 +1,39 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.internal + +import androidx.compose.ui.text.AnnotatedString +import org.junit.Test + +class TimeTransformationTest { + + private val transformation = TimeTransformation() + + @Test + fun timeTransformationShouldWorkCorrectly() { + val transformedText = transformation + .filter(AnnotatedString("1045")) + .text + .toString() + + assert(transformedText == "10:45") + } + + @Test + fun partialTimeTransformationShouldWorkCorrectly() { + val transformedText = transformation + .filter(AnnotatedString("104")) + .text + .toString() + + assert(transformedText == "10:4M") + } + + @Test + fun emptyTextShouldDisplayDateMask() { + val transformedText = transformation + .filter(AnnotatedString("")) + .text + .toString() + + assert(transformedText == "HH:MM") + } +} From 2014aa7a8a3a18dc5873fd48aadaa1c6efd1415b Mon Sep 17 00:00:00 2001 From: DavidAparicioAlbaAsenjo <137989685+DavidAparicioAlbaAsenjo@users.noreply.github.com> Date: Mon, 16 Oct 2023 16:24:18 +0200 Subject: [PATCH 09/12] ANDROAPP-5617-mobile-ui-Horizontal-RadioButoons-and-checkboxes-resize-when-clear-button-appears (#101) * update: [ANDROAPP-5617] issue fixed * update: [ANDROAPP-5617] Code formatted * update: [ANDROAPP-5617] using 48dp spacer --- .../dhis/mobile/ui/designsystem/component/InputCheckBox.kt | 4 ++++ .../dhis/mobile/ui/designsystem/component/InputRadioButton.kt | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputCheckBox.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputCheckBox.kt index 5d4d0786b..c6e5fdada 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputCheckBox.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputCheckBox.kt @@ -1,6 +1,8 @@ package org.hisp.dhis.mobile.ui.designsystem.component +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material3.Icon @@ -85,6 +87,8 @@ fun InputCheckBox( onClearSelection.invoke() }, ) + } else { + Spacer(modifier = Modifier.width(Spacing.Spacing48)) } }, ) diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputRadioButton.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputRadioButton.kt index e489feb79..d8423e09f 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputRadioButton.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputRadioButton.kt @@ -1,6 +1,8 @@ package org.hisp.dhis.mobile.ui.designsystem.component +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material3.Icon @@ -89,6 +91,8 @@ fun InputRadioButton( onItemChange.invoke(null) }, ) + } else { + Spacer(modifier = Modifier.width(Spacing.Spacing48)) } }, ) From d0bb09175b7c895ed2ab6b77f343839a7f173597 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Tue, 17 Oct 2023 14:08:01 +0530 Subject: [PATCH 10/12] Add support for setting legend data to input age component (#108) * Scope legend composable to column scope * Add legend data as param to `InputAge` component * Remove onFocusChanged from `InputAge` * Update text button selector text styles --- .../hisp/dhis/common/screens/InputAgeScreen.kt | 18 ++++++++++++++++++ .../mobile/ui/designsystem/component/Button.kt | 3 ++- .../ui/designsystem/component/InputAge.kt | 8 ++++++-- .../ui/designsystem/component/InputShell.kt | 5 +++-- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputAgeScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputAgeScreen.kt index f55210902..9082a8a29 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputAgeScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputAgeScreen.kt @@ -5,15 +5,18 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import org.hisp.dhis.common.screens.previews.regularLegendList import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer import org.hisp.dhis.mobile.ui.designsystem.component.InputAge import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.LegendData import org.hisp.dhis.mobile.ui.designsystem.component.Orientation import org.hisp.dhis.mobile.ui.designsystem.component.RadioButtonData import org.hisp.dhis.mobile.ui.designsystem.component.SubTitle import org.hisp.dhis.mobile.ui.designsystem.component.TimeUnitSelector import org.hisp.dhis.mobile.ui.designsystem.component.TimeUnitValues +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor @Composable fun InputAgeScreen() { @@ -106,5 +109,20 @@ fun InputAgeScreen() { // no-op }, ) + + SubTitle("Input Age Component - Legend") + InputAge( + title = "Label", + inputType = AgeInputType.Age(value = "56", unit = TimeUnitValues.YEARS), + state = InputShellState.ERROR, + isRequired = true, + onCalendarActionClicked = { + // no-op + }, + onValueChanged = { + // no-op + }, + legendData = LegendData(SurfaceColor.CustomGreen, "Legend", popUpLegendDescriptionData = regularLegendList), + ) } } diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Button.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Button.kt index 135144953..75d221f94 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Button.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Button.kt @@ -276,12 +276,13 @@ fun TextButtonSelector( Text( text = middleText, modifier = Modifier.padding(start = Spacing.Spacing8, end = Spacing.Spacing8), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.bodyLarge, color = if (enabled) TextColor.OnSurfaceVariant else TextColor.OnDisabledSurface, ) Text( text = secondOptionText, color = clickableText2Color.value, + style = MaterialTheme.typography.titleMedium, modifier = Modifier.clickable( enabled = enabled, interactionSource = interactionSourceOption2, 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 2bba32129..5db81558d 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 @@ -33,6 +33,7 @@ import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing * [Age]: Age value with appropriate time unit * @param onCalendarActionClicked: Callback to handle the action when the calendar icon is clicked. * @param state: [InputShellState] + * @param legendData: [LegendData] * @param supportingText: List of [SupportingTextData] that manages all the messages to be shown. * @param isRequired: Mark this input as marked * @param onValueChanged: Callback to receive changes in the input @@ -44,13 +45,13 @@ fun InputAge( onCalendarActionClicked: () -> Unit, modifier: Modifier = Modifier, state: InputShellState = InputShellState.UNFOCUSED, + legendData: LegendData? = null, supportingText: List? = null, isRequired: Boolean = false, imeAction: ImeAction = ImeAction.Next, dateOfBirthLabel: String = provideStringResource("date_birth"), orLabel: String = provideStringResource("or"), ageLabel: String = provideStringResource("age"), - onFocusChanged: ((Boolean) -> Unit) = {}, onValueChanged: (AgeInputType) -> Unit, ) { val maxAgeCharLimit = 3 @@ -93,7 +94,6 @@ fun InputAge( title = title, state = state, isRequiredField = isRequired, - onFocusChanged = onFocusChanged, inputField = { when (inputType) { None -> { @@ -185,6 +185,10 @@ fun InputAge( }, ) } + + legendData?.let { + Legend(legendData, Modifier.testTag("INPUT_AGE_LEGEND")) + } }, ) } diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputShell.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputShell.kt index dda616f85..769d37725 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputShell.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputShell.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -52,7 +53,7 @@ fun InputShell( secondaryButton: @Composable (() -> Unit)? = null, inputField: @Composable (() -> Unit)? = null, supportingText: @Composable (() -> Unit)? = null, - legend: @Composable (() -> Unit)? = null, + legend: @Composable (ColumnScope.() -> Unit)? = null, onFocusChanged: ((Boolean) -> Unit)? = null, isRequiredField: Boolean = false, modifier: Modifier = Modifier, @@ -117,7 +118,7 @@ fun InputShell( thickness = indicatorThickness, ) } - legend?.invoke() + legend?.invoke(this) if (state != InputShellState.DISABLED) supportingText?.invoke() if (isRequiredField && state == InputShellState.ERROR && supportingText == null) SupportingText("Required", state = SupportingTextState.ERROR) } From a4e8c9d8aa90defb5b73042e1609369bc269feed Mon Sep 17 00:00:00 2001 From: manu Date: Mon, 16 Oct 2023 17:17:19 +0200 Subject: [PATCH 11/12] make chip color lighter --- .../org/hisp/dhis/mobile/ui/designsystem/component/Chip.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Chip.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Chip.kt index 104de8c18..c9bef5fa5 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Chip.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Chip.kt @@ -21,8 +21,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntOffset +import org.hisp.dhis.mobile.ui.designsystem.theme.Outline import org.hisp.dhis.mobile.ui.designsystem.theme.Ripple import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -37,12 +39,15 @@ fun Chip( CompositionLocalProvider(LocalRippleTheme provides Ripple.CustomDHISRippleTheme) { FilterChip( onClick = { onSelected?.invoke(!selected) }, - label = { Text(label) }, + label = { Text(label, color = TextColor.OnSurfaceLight) }, selected = selected, colors = FilterChipDefaults.filterChipColors( containerColor = SurfaceColor.SurfaceBright, selectedContainerColor = SurfaceColor.Container, ), + border = FilterChipDefaults.filterChipBorder( + borderColor = Outline.Medium, + ), leadingIcon = if (selected) { { Icon( From dcc92bf496125e6ad818c0f93b6fe1b677955a04 Mon Sep 17 00:00:00 2001 From: Siddharth Agarwal Date: Wed, 18 Oct 2023 15:49:36 +0530 Subject: [PATCH 12/12] ANDROAPP-5611-mobile-ui-Create-coordinates-component (#104) --- .../kotlin/org/hisp/dhis/common/App.kt | 2 + .../hisp/dhis/common/screens/Components.kt | 1 + .../common/screens/InputCoordinateScreen.kt | 73 ++++++++ .../designsystem/component/InputCoordinate.kt | 171 ++++++++++++++++++ .../resources/values/strings_en.xml | 3 + .../component/InputCoordinateTest.kt | 162 +++++++++++++++++ 6 files changed, 412 insertions(+) create mode 100644 common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputCoordinateScreen.kt create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputCoordinate.kt create mode 100644 designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputCoordinateTest.kt diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt index 8b7beef5f..22f32bcca 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -38,6 +38,7 @@ import org.hisp.dhis.common.screens.ImageBlockScreen import org.hisp.dhis.common.screens.InputAgeScreen import org.hisp.dhis.common.screens.InputBarCodeScreen import org.hisp.dhis.common.screens.InputCheckBoxScreen +import org.hisp.dhis.common.screens.InputCoordinateScreen import org.hisp.dhis.common.screens.InputDateTimeScreen import org.hisp.dhis.common.screens.InputDropDownScreen import org.hisp.dhis.common.screens.InputEmailScreen @@ -181,6 +182,7 @@ fun Main() { Components.IMAGE_BLOCK -> ImageBlockScreen() Components.INPUT_DROPDOWN -> InputDropDownScreen() Components.INPUT_DATE_TIME -> InputDateTimeScreen() + Components.INPUT_COORDINATE -> InputCoordinateScreen() } } } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt index 7b63e6516..7a23eeabd 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt @@ -51,4 +51,5 @@ enum class Components(val label: String) { IMAGE_BLOCK("Image Block"), INPUT_DROPDOWN("Input Dropdown"), INPUT_DATE_TIME("Input Date Time"), + INPUT_COORDINATE("Input Coordinate"), } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputCoordinateScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputCoordinateScreen.kt new file mode 100644 index 000000000..4b840cfb2 --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputCoordinateScreen.kt @@ -0,0 +1,73 @@ +package org.hisp.dhis.common.screens + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.Coordinates +import org.hisp.dhis.mobile.ui.designsystem.component.InputCoordinate +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.SubTitle +import org.hisp.dhis.mobile.ui.designsystem.component.Title +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +@Composable +fun InputCoordinateScreen() { + ColumnComponentContainer { + Title("Input Coordinates", textColor = TextColor.OnSurfaceVariant) + + SubTitle("Basic Input Coordinates ", textColor = TextColor.OnSurfaceVariant) + var coordinates by rememberSaveable { mutableStateOf(null) } + InputCoordinate( + title = "Label", + state = InputShellState.UNFOCUSED, + coordinates = coordinates, + onResetButtonClicked = { + coordinates = null + }, + onUpdateButtonClicked = { + coordinates = Coordinates(latitude = 39.46263, longitude = -0.33617) + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + + SubTitle("Disabled Input Coordinates without data ", textColor = TextColor.OnSurfaceVariant) + var coordinates1 by rememberSaveable { + mutableStateOf(null) + } + InputCoordinate( + title = "Label", + state = InputShellState.DISABLED, + coordinates = coordinates1, + onResetButtonClicked = { + coordinates1 = null + }, + onUpdateButtonClicked = { + coordinates1 = Coordinates(latitude = 39.46263, longitude = -0.33617) + }, + ) + + SubTitle("Disabled Input Coordinates with data ", textColor = TextColor.OnSurfaceVariant) + var coordinates2 by rememberSaveable { + mutableStateOf(Coordinates(latitude = 39.46263, longitude = -0.33617)) + } + InputCoordinate( + title = "Label", + state = InputShellState.DISABLED, + coordinates = coordinates2, + onResetButtonClicked = { + coordinates2 = null + }, + onUpdateButtonClicked = { + coordinates2 = Coordinates(latitude = 39.46263, longitude = -0.33617) + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputCoordinate.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputCoordinate.kt new file mode 100644 index 000000000..ffef7854d --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputCoordinate.kt @@ -0,0 +1,171 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddLocationAlt +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.EditLocationAlt +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import org.hisp.dhis.mobile.ui.designsystem.resource.provideStringResource +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +/** + * DHIS2 Input coordinate. Wraps DHIS · [InputShell]. + * @param title controls the text to be shown for the title + * @param state Manages the InputShell state + * @param supportingText is a list of SupportingTextData that + * manages all the messages to be shown + * @param legendData manages the legendComponent + * @param coordinates controls the latitude and longitude of the location + * @param latitudeText controls the text to be shown for the latitude label + * @param longitudeText controls the text to be shown for the longitude label + * @param addLocationBtnText controls the text to be shown for the add polygon button + * @param isRequired controls whether the field is mandatory or not + * @param modifier allows a modifier to be passed externally + * @param onResetButtonClicked callback to when reset button is clicked + * @param onUpdateButtonClicked callback to when add button or edit icon is clicked + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun InputCoordinate( + title: String, + state: InputShellState = InputShellState.UNFOCUSED, + supportingText: List? = null, + legendData: LegendData? = null, + coordinates: Coordinates? = null, + latitudeText: String = provideStringResource("latitude"), + longitudeText: String = provideStringResource("longitude"), + addLocationBtnText: String = provideStringResource("add_location"), + isRequired: Boolean = false, + modifier: Modifier = Modifier, + onResetButtonClicked: () -> Unit, + onUpdateButtonClicked: () -> Unit, +) { + InputShell( + modifier = modifier.testTag("INPUT_COORDINATE"), + title = title, + state = state, + isRequiredField = isRequired, + legend = { + legendData?.let { + Legend(legendData, modifier.testTag("INPUT_COORDINATE_LEGEND")) + } + }, + supportingText = { + supportingText?.forEach { label -> + SupportingText( + label.text, + label.state, + modifier = modifier.testTag("INPUT_COORDINATE_SUPPORTING_TEXT"), + ) + } + }, + inputField = { + if (coordinates != null) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Spacing.Spacing16), + modifier = Modifier.padding(end = Spacing.Spacing16), + ) { + CoordinateText( + latitudeText, + coordinates.latitude.toString(), + state == InputShellState.DISABLED, + ) + CoordinateText( + longitudeText, + coordinates.longitude.toString(), + state == InputShellState.DISABLED, + ) + } + } else { + Button( + enabled = state != InputShellState.DISABLED, + ButtonStyle.KEYBOARDKEY, + addLocationBtnText, + icon = { + Icon( + imageVector = Icons.Outlined.AddLocationAlt, + contentDescription = "Add Location Button", + ) + }, + Modifier + .fillMaxWidth() + .padding(end = Spacing.Spacing12, top = Spacing.Spacing8, bottom = Spacing.Spacing8) + .testTag("INPUT_COORDINATE_ADD_BUTTON"), + ) { + onUpdateButtonClicked.invoke() + } + } + }, + primaryButton = if (coordinates != null && state != InputShellState.DISABLED) { + { + IconButton( + modifier = Modifier.testTag("INPUT_COORDINATE_RESET_BUTTON"), + icon = { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = "Reset Button", + ) + }, + onClick = onResetButtonClicked, + ) + } + } else { + null + }, + secondaryButton = if (coordinates != null && state != InputShellState.DISABLED) { + { + SquareIconButton( + modifier = Modifier.testTag("INPUT_COORDINATE_EDIT_BUTTON"), + enabled = true, + icon = { + Icon( + imageVector = Icons.Outlined.EditLocationAlt, + contentDescription = "edit_location", + ) + }, + onClick = {}, + ) + } + } else { + null + }, + ) +} + +@Composable +fun CoordinateText(text: String, value: String, isDisabled: Boolean) { + Text( + style = MaterialTheme.typography.bodyLarge.copy( + color = if (!isDisabled) { + TextColor.OnSurface + } else { + TextColor.OnDisabledSurface + }, + ), + text = buildAnnotatedString { + withStyle(style = SpanStyle(color = TextColor.OnDisabledSurface)) { + append("$text: ") + } + append(value) + }, + ) +} + +data class Coordinates( + val latitude: Double, + val longitude: Double, +) diff --git a/designsystem/src/commonMain/resources/values/strings_en.xml b/designsystem/src/commonMain/resources/values/strings_en.xml index 1b9cc504f..bdb00f899 100644 --- a/designsystem/src/commonMain/resources/values/strings_en.xml +++ b/designsystem/src/commonMain/resources/values/strings_en.xml @@ -22,4 +22,7 @@ Sync Add polygon Polygon captured + Add location + Lat + Long diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputCoordinateTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputCoordinateTest.kt new file mode 100644 index 000000000..51b8f80e5 --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputCoordinateTest.kt @@ -0,0 +1,162 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.junit.Rule +import org.junit.Test + +class InputCoordinateTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun shouldDisplayInputCoordinateCorrectly() { + rule.setContent { + InputCoordinate( + title = "Label", + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_COORDINATE").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_LEGEND").assertDoesNotExist() + rule.onNodeWithTag("INPUT_COORDINATE_SUPPORTING_TEXT").assertDoesNotExist() + } + + @Test + fun shouldAllowAddCoordinateWhenEnabled() { + rule.setContent { + InputCoordinate( + title = "Label", + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_COORDINATE").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_ADD_BUTTON").assertIsEnabled() + } + + @Test + fun shouldNotAllowAddCoordinateWhenDisabled() { + rule.setContent { + InputCoordinate( + title = "Label", + state = InputShellState.DISABLED, + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_COORDINATE").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_ADD_BUTTON").assertIsNotEnabled() + } + + @Test + fun shouldShowResetAndEditButtonWhenCoordinateAdded() { + rule.setContent { + InputCoordinate( + title = "Label", + coordinates = Coordinates(latitude = 39.46263, longitude = -0.33617), + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_COORDINATE").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_ADD_BUTTON").assertDoesNotExist() + rule.onNodeWithTag("INPUT_COORDINATE_RESET_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_EDIT_BUTTON").assertExists() + } + + @Test + fun shouldHideResetAndEditButtonWhenNoCoordinateAdded() { + rule.setContent { + InputCoordinate( + title = "Label", + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_COORDINATE").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_ADD_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_RESET_BUTTON").assertDoesNotExist() + rule.onNodeWithTag("INPUT_COORDINATE_EDIT_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldRemoveCoordinateWhenResetButtonIsClickedAndHideResetAndEditButton() { + rule.setContent { + var coordinates by rememberSaveable { + mutableStateOf(Coordinates(latitude = 39.46263, longitude = -0.33617)) + } + + InputCoordinate( + title = "Label", + coordinates = coordinates, + onResetButtonClicked = { + coordinates = null + }, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_COORDINATE").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_RESET_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_RESET_BUTTON").performClick() + + rule.onNodeWithTag("INPUT_COORDINATE_ADD_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_RESET_BUTTON").assertDoesNotExist() + rule.onNodeWithTag("INPUT_COORDINATE_EDIT_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldHideResetAndEditButtonWhenDisabled() { + rule.setContent { + InputCoordinate( + title = "Label", + state = InputShellState.DISABLED, + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_COORDINATE").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_RESET_BUTTON").assertDoesNotExist() + rule.onNodeWithTag("INPUT_COORDINATE_EDIT_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldShowLegendCorrectly() { + rule.setContent { + InputCoordinate( + title = "Label", + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_COORDINATE").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_LEGEND").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_LEGEND").assertHasClickAction() + } + + @Test + fun shouldShowSupportingTextCorrectly() { + rule.setContent { + InputCoordinate( + title = "Label", + supportingText = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + onResetButtonClicked = {}, + onUpdateButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_COORDINATE").assertExists() + rule.onNodeWithTag("INPUT_COORDINATE_SUPPORTING_TEXT").assertExists() + } +}