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() + } +}