From 7889fac1a2ae1d2a3243b089303ecfef358dadc9 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 30 Sep 2024 12:59:32 +0200 Subject: [PATCH] Location search bar (#300) Signed-off-by: Pablo --- .../org/hisp/dhis/android/MainActivity.kt | 21 + common/build.gradle.kts | 2 +- .../kotlin/org/hisp/dhis/common/App.kt | 26 +- .../org/hisp/dhis/common/screens/Groups.kt | 1 + .../location/LocationSearchBarScreen.kt | 64 +++ .../LocationBarButtonSnapshotTest.kt | 23 + .../LocationBarSearchSnapshotTest.kt | 39 ++ .../component/LocationSearchBar.kt | 412 ++++++++++++++++++ .../ui/designsystem/component/SearchBar.kt | 8 +- .../internal/modifiers/ClickableWithRipple.kt | 25 ++ .../component/model/LocationItemModel.kt | 46 ++ .../resources/values-es/strings.xml | 4 + .../commonMain/resources/values/strings.xml | 4 + .../component/LocationSearchBarTest.kt | 214 +++++++++ ...SnapshotTest_launchSearchBarButtonTest.png | 3 + ...SnapshotTest_launchSearchBarButtonTest.png | 3 + 16 files changed, 889 insertions(+), 6 deletions(-) create mode 100644 common/src/commonMain/kotlin/org/hisp/dhis/common/screens/location/LocationSearchBarScreen.kt create mode 100644 designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarButtonSnapshotTest.kt create mode 100644 designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarSearchSnapshotTest.kt create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBar.kt create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/modifiers/ClickableWithRipple.kt create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/model/LocationItemModel.kt create mode 100644 designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBarTest.kt create mode 100644 designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarButtonSnapshotTest_launchSearchBarButtonTest.png create mode 100644 designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarSearchSnapshotTest_launchSearchBarButtonTest.png diff --git a/android/src/main/java/org/hisp/dhis/android/MainActivity.kt b/android/src/main/java/org/hisp/dhis/android/MainActivity.kt index b17de2cd7..4e9416a17 100644 --- a/android/src/main/java/org/hisp/dhis/android/MainActivity.kt +++ b/android/src/main/java/org/hisp/dhis/android/MainActivity.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.core.view.WindowCompat import org.hisp.dhis.common.App +import org.hisp.dhis.mobile.ui.designsystem.component.model.LocationItemModel class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -28,6 +29,26 @@ class MainActivity : AppCompatActivity() { .also { it.inPreferredConfig = Bitmap.Config.ARGB_8888 }, ).asImageBitmap() }, + onLocationRequest = { locationQuery, locationSearchCallback -> + + if (locationQuery.isNotBlank()) { + val fakeList = buildList { + repeat(20) { + add( + LocationItemModel.SearchResult( + "Fake Location Title #$it", + "Fake Location Address, Fake Country, Fake City", + 0.0, + 0.0, + ), + ) + } + } + locationSearchCallback(fakeList) + } else { + locationSearchCallback(emptyList()) + } + }, ) } } diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 3c7df18a1..1cd8290cc 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -18,7 +18,7 @@ kotlin { implementation(compose.material3) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.components.resources) - implementation(project(":designsystem")) + api(project(":designsystem")) } commonTest.dependencies { implementation(kotlin("test")) 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 4f37602d7..5a90042c8 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -19,6 +19,7 @@ import org.hisp.dhis.common.screens.basicTextInputs.BasicTextInputsScreen import org.hisp.dhis.common.screens.bottomSheets.BottomSheetsScreen import org.hisp.dhis.common.screens.buttons.ButtonsScreen import org.hisp.dhis.common.screens.cards.CardsScreen +import org.hisp.dhis.common.screens.location.LocationSearchBarScreen import org.hisp.dhis.common.screens.others.BadgesScreen import org.hisp.dhis.common.screens.others.ChipsScreen import org.hisp.dhis.common.screens.others.IndicatorScreen @@ -38,21 +39,36 @@ 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.InputStyle import org.hisp.dhis.mobile.ui.designsystem.component.MetadataAvatarSize +import org.hisp.dhis.mobile.ui.designsystem.component.model.LocationItemModel import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme 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 @Composable -fun App(imageBitmapLoader: (() -> ImageBitmap)? = null) { +fun App( + imageBitmapLoader: (() -> ImageBitmap)? = null, + onLocationRequest: ( + ( + locationQuery: String, + locationSearchCallback: (List) -> Unit, + ) -> Unit + )? = null, +) { DHIS2Theme { - Main(imageBitmapLoader) + Main(imageBitmapLoader, onLocationRequest) } } @Composable fun Main( imageBitmapLoader: (() -> ImageBitmap)?, + onLocationRequest: ( + ( + locationQuery: String, + locationSearchCallback: (List) -> Unit, + ) -> Unit + )?, ) { val currentScreen = remember { mutableStateOf(Groups.NO_GROUP_SELECTED) } var isComponentSelected by remember { mutableStateOf(false) } @@ -81,7 +97,8 @@ fun Main( state = InputShellState.UNFOCUSED, expanded = true, selectedItem = DropdownItem(currentScreen.value.label), - inputStyle = InputStyle.DataInputStyle().apply { backGroundColor = SurfaceColor.SurfaceBright }, + inputStyle = InputStyle.DataInputStyle() + .apply { backGroundColor = SurfaceColor.SurfaceBright }, ) when (currentScreen.value) { @@ -105,6 +122,9 @@ fun Main( Groups.MENU -> MenuItemScreen() Groups.NO_GROUP_SELECTED -> NoComponentSelectedScreen() Groups.TOP_BAR -> TopBarScreen() + Groups.LOCATION_SEARCH_BAR -> LocationSearchBarScreen { locationQuery, locationCallback -> + onLocationRequest?.invoke(locationQuery, locationCallback) + } } } else { NoComponentSelectedScreen( diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Groups.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Groups.kt index 703580911..e48911006 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Groups.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Groups.kt @@ -21,4 +21,5 @@ enum class Groups(val label: String) { TOP_BAR("Top Bar"), MENU("Menu"), NO_GROUP_SELECTED("No group selected"), + LOCATION_SEARCH_BAR("Location Search Bar"), } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/location/LocationSearchBarScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/location/LocationSearchBarScreen.kt new file mode 100644 index 000000000..ba47fc450 --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/location/LocationSearchBarScreen.kt @@ -0,0 +1,64 @@ +package org.hisp.dhis.common.screens.location + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.Alignment.Companion.TopCenter +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.hisp.dhis.mobile.ui.designsystem.component.LocationBar +import org.hisp.dhis.mobile.ui.designsystem.component.model.LocationItemModel + +@Composable +fun LocationSearchBarScreen( + onSearchLocation: ( + locationQuery: String, + locationSearchCallback: (List) -> Unit, + + ) -> Unit, +) { + var itemList: List by remember { + mutableStateOf(defaultLocationItems) + } + Box( + modifier = Modifier.fillMaxSize() + .background(Color.White) + .padding(16.dp), + contentAlignment = TopCenter, + ) { + LocationBar( + currentResults = itemList, + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = { locationQuery -> + onSearchLocation(locationQuery) { + itemList = it.takeIf { locationQuery.isNotBlank() } ?: defaultLocationItems + } + }, + onLocationSelected = { locationItemModel -> + }, + ) + } +} + +private val defaultLocationItems = listOf( + LocationItemModel.StoredResult( + storedTitle = "Location #1", + storedSubtitle = "Location description, location description, location description", + storedLatitude = 0.0, + storedLongitude = 0.0, + ), + LocationItemModel.StoredResult( + storedTitle = "Location #2", + storedSubtitle = "Location description, location description, location description", + storedLatitude = 0.0, + storedLongitude = 0.0, + ), +) diff --git a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarButtonSnapshotTest.kt b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarButtonSnapshotTest.kt new file mode 100644 index 000000000..284e76e84 --- /dev/null +++ b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarButtonSnapshotTest.kt @@ -0,0 +1,23 @@ +package org.hisp.dhis.mobile.ui.designsystem + +import org.hisp.dhis.mobile.ui.designsystem.component.LocationBar +import org.junit.Rule +import org.junit.Test + +class LocationBarButtonSnapshotTest { + @get:Rule + val paparazzi = paparazzi() + + @Test + fun launchSearchBarButtonTest() { + paparazzi.snapshot { + LocationBar( + currentResults = emptyList(), + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = {}, + onLocationSelected = {}, + ) + } + } +} diff --git a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarSearchSnapshotTest.kt b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarSearchSnapshotTest.kt new file mode 100644 index 000000000..39604f572 --- /dev/null +++ b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarSearchSnapshotTest.kt @@ -0,0 +1,39 @@ +package org.hisp.dhis.mobile.ui.designsystem + +import org.hisp.dhis.mobile.ui.designsystem.component.LocationBar +import org.hisp.dhis.mobile.ui.designsystem.component.SearchBarMode +import org.hisp.dhis.mobile.ui.designsystem.component.model.LocationItemModel +import org.junit.Rule +import org.junit.Test + +class LocationBarSearchSnapshotTest { + @get:Rule + val paparazzi = paparazzi() + + @Test + fun launchSearchBarButtonTest() { + paparazzi.snapshot { + LocationBar( + currentResults = listOf( + LocationItemModel.StoredResult( + "Location Item title", + "Location Item address", + 0.0, + 0.0, + ), + LocationItemModel.SearchResult( + "Location Item title 2", + "Location Item address 2", + 0.0, + 0.0, + ), + ), + mode = SearchBarMode.SEARCH, + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = {}, + onLocationSelected = {}, + ) + } + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBar.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBar.kt new file mode 100644 index 000000000..08b453a08 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBar.kt @@ -0,0 +1,412 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.filled.TravelExplore +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.Map +import androidx.compose.material.icons.outlined.Place +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.WatchLater +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Alignment.Companion.Top +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.hisp.dhis.mobile.ui.designsystem.component.internal.modifiers.clickableWithRipple +import org.hisp.dhis.mobile.ui.designsystem.component.model.LocationItemModel +import org.hisp.dhis.mobile.ui.designsystem.resource.provideStringResource +import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2SCustomTextStyles +import org.hisp.dhis.mobile.ui.designsystem.theme.Outline +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.Spacing16 +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +/** + * DHIS2 Location Bar modes + * BUTTON: the Location Bar is displayed as a button and shows current search query if available. + * SEARCH: The Location Bar is displayed as an input and displays available location items. + */ +enum class SearchBarMode { + BUTTON, + SEARCH, +} + +/** + * DHIS2 Location Bar. + * @param currentResults: the available location items to display before/after search. + * @param mode: the initial mode for the composable. + * @param onBackClicked: callback for when the back button is clicked. + * @param onClearLocation: callback for when the clear location button is clicked. + * @param onSearchLocation: callback for when the search location button is clicked. + * @param onLocationSelected: callback for when a location item is selected. + * @param onModeChanged: optional callback for when the mode is changed. + */ +@Composable +fun LocationBar( + currentResults: List, + mode: SearchBarMode = SearchBarMode.BUTTON, + onBackClicked: () -> Unit, + onClearLocation: () -> Unit, + onSearchLocation: (query: String) -> Unit, + onLocationSelected: (LocationItemModel) -> Unit, + onModeChanged: (currentMode: SearchBarMode) -> Unit = {}, +) { + var currentMode by remember { mutableStateOf(mode) } + var currentSearch: String by remember { mutableStateOf("") } + + LaunchedEffect(currentMode) { + onModeChanged(currentMode) + } + + when (currentMode) { + SearchBarMode.BUTTON -> LocationSearchBarButton( + currentSearch = currentSearch, + onBackClicked = onBackClicked, + onClearLocation = { + currentSearch = "" + onClearLocation() + }, + onClick = { + currentMode = SearchBarMode.SEARCH + }, + ) + + SearchBarMode.SEARCH -> LocationSearchBar( + currentSearch = currentSearch, + currentResults = currentResults, + onSearchChanged = { + currentSearch = it + onSearchLocation(currentSearch) + }, + onBackClicked = { + currentMode = SearchBarMode.BUTTON + }, + onSearch = { searchQuery -> + currentMode = SearchBarMode.BUTTON + }, + onLocationSelected = { + currentSearch = it.title + currentMode = SearchBarMode.BUTTON + onLocationSelected(it) + }, + ) + } +} + +@Composable +private fun LocationSearchBarButton( + currentSearch: String = "", + onBackClicked: () -> Unit, + onClearLocation: () -> Unit, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .testTag("SEARCH_BAR_BUTTON") + .fillMaxWidth() + .wrapContentHeight() + .clip(Shape.Full) + .clickable(onClick = onClick) + .background( + color = SurfaceColor.ContainerLow, + shape = Shape.Full, + ) + .padding(Spacing.Spacing4), + verticalAlignment = CenterVertically, + horizontalArrangement = spacedBy(4.dp), + ) { + IconButton( + style = IconButtonStyle.STANDARD, + onClick = onBackClicked, + icon = { + Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = null) + }, + ) + Text( + modifier = Modifier.weight(1f), + text = currentSearch.takeIf { it.isNotBlank() } ?: "Search location", + style = MaterialTheme.typography.bodyLarge, + color = if (currentSearch.isBlank()) { + TextColor.OnDisabledSurface + } else { + TextColor.OnSurface + }, + ) + if (currentSearch.isEmpty()) { + IconButton( + style = IconButtonStyle.STANDARD, + onClick = {}, + icon = { + Icon(imageVector = Icons.Outlined.Search, contentDescription = null) + }, + ) + } else { + IconButton( + style = IconButtonStyle.STANDARD, + onClick = onClearLocation, + icon = { + Icon(imageVector = Icons.Outlined.Cancel, contentDescription = null) + }, + ) + } + } +} + +@Composable +private fun LocationSearchBar( + currentSearch: String = "", + currentResults: List, + onSearchChanged: (String) -> Unit, + onSearch: (String) -> Unit, + onBackClicked: () -> Unit, + onLocationSelected: (LocationItemModel) -> Unit, +) { + val scrollState = rememberLazyListState() + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + var needsToFocus by remember { mutableStateOf(true) } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = spacedBy(Spacing.Spacing16), + ) { + SearchBar( + text = currentSearch, + placeHolderText = provideStringResource("search_location"), + onActiveChange = {}, + onQueryChange = onSearchChanged, + onSearch = onSearch, + leadingIcon = { + IconButton( + style = IconButtonStyle.STANDARD, + onClick = onBackClicked, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = null, + ) + }, + ) + }, + focusRequester = focusRequester, + ) + + LaunchedEffect(scrollState.isScrollInProgress, needsToFocus) { + if (scrollState.isScrollInProgress) { + focusRequester.freeFocus() + keyboardController?.hide() + } else if (needsToFocus) { + focusRequester.requestFocus() + needsToFocus = false + } + } + + LazyColumn(modifier = Modifier.fillMaxWidth(), state = scrollState) { + when { + currentResults.isNotEmpty() -> + itemsIndexed(items = currentResults) { index, locationItemModel -> + SearchResultLocationItem( + modifier = Modifier.testTag("LOCATION_ITEM_$index"), + locationItemModel, + ) { + onLocationSelected(locationItemModel) + } + } + + else -> + item { + NoResultsMessage(isSearching = currentSearch.isNotBlank()) + } + } + + item { + HorizontalDivider( + thickness = 1.dp, + color = Outline.Medium, + modifier = Modifier.padding(vertical = Spacing16), + ) + } + + item { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + ) { + Button( + modifier = Modifier.fillMaxWidth(), + style = ButtonStyle.TONAL, + icon = { + Icon( + imageVector = Icons.Outlined.Map, + contentDescription = "touch app", + ) + }, + text = provideStringResource("select_in_map"), + onClick = onBackClicked, + ) + } + } + } + } +} + +@Composable +fun LocationItem( + modifier: Modifier = Modifier, + locationItemModel: LocationItemModel, + icon: @Composable () -> Unit, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(Shape.Small) + .clickableWithRipple(onClick = onClick) + .padding(Spacing.Spacing8), + horizontalArrangement = spacedBy(Spacing.Spacing16), + verticalAlignment = Top, + ) { + icon() + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = locationItemModel.title, + style = DHIS2SCustomTextStyles.titleMediumBold, + color = TextColor.OnPrimaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = locationItemModel.subtitle, + style = MaterialTheme.typography.bodySmall, + color = TextColor.OnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun SearchResultLocationItem( + modifier: Modifier = Modifier, + locationItemModel: LocationItemModel, + onClick: () -> Unit, +) { + val icon = when (locationItemModel) { + is LocationItemModel.StoredResult -> Icons.Outlined.WatchLater + is LocationItemModel.SearchResult -> Icons.Outlined.Place + } + val tintedColor = when (locationItemModel) { + is LocationItemModel.StoredResult -> SurfaceColor.Warning + is LocationItemModel.SearchResult -> SurfaceColor.Primary + } + + val bgColor = when (locationItemModel) { + is LocationItemModel.StoredResult -> SurfaceColor.WarningContainer + is LocationItemModel.SearchResult -> SurfaceColor.PrimaryContainer + } + + LocationItem( + modifier = modifier, + locationItemModel = locationItemModel, + icon = { + LocationItemIcon( + icon = icon, + tintedColor = tintedColor, + bgColor = bgColor, + ) + }, + onClick = onClick, + ) +} + +/** + * DHIS2 Location Item icon. + * @param icon: the ImageVector to display as an icon. + * @param tintedColor: the color to tint the icon with. + * @param bgColor: the color for the background. + */ +@Composable +fun LocationItemIcon( + icon: ImageVector, + tintedColor: Color, + bgColor: Color, +) { + Box( + modifier = Modifier.size(Spacing.Spacing40) + .clip(Shape.Full) + .background(color = bgColor, shape = Shape.Full), + contentAlignment = Center, + ) { + Icon( + imageVector = icon, + tint = tintedColor, + contentDescription = "location icon", + ) + } +} + +@Composable +private fun NoResultsMessage(isSearching: Boolean) { + val message = if (!isSearching) { + provideStringResource("no_recent_results") + } else { + provideStringResource("no_results") + } + + Column( + modifier = Modifier + .testTag(if (!isSearching) "NO_RECENT_RESULTS" else "NO_RESULTS") + .fillMaxWidth() + .padding(vertical = 64.dp), + verticalArrangement = spacedBy(16.dp), + horizontalAlignment = CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Filled.TravelExplore, + tint = SurfaceColor.ContainerHighest, + contentDescription = "Travel explore", + ) + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = TextColor.OnSurfaceVariant, + ) + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/SearchBar.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/SearchBar.kt index afe75b675..5fc2d99b1 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/SearchBar.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/SearchBar.kt @@ -57,6 +57,8 @@ import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor * @param onQueryChange: on query change callback. * @param state: input shell state. * @param modifier: optional modifier. + * @param leadingIcon: optional leading icon to display. + * @param focusRequester: optional focus requester. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -68,10 +70,11 @@ fun SearchBar( onQueryChange: (String) -> Unit = {}, state: InputShellState = InputShellState.FOCUSED, modifier: Modifier = Modifier, + leadingIcon: @Composable (() -> Unit)? = null, + focusRequester: FocusRequester = remember { FocusRequester() }, ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() - val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current val containerColor = if (!isPressed) { @@ -114,7 +117,7 @@ fun SearchBar( false } } - .padding(end = Spacing.Spacing4) + .padding(horizontal = Spacing.Spacing4) .semantics { contentDescription = "Search" }, @@ -138,6 +141,7 @@ fun SearchBar( color = TextColor.OnDisabledSurface, ) }, + leadingIcon = leadingIcon, trailingIcon = { if (text != "") { IconButton( diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/modifiers/ClickableWithRipple.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/modifiers/ClickableWithRipple.kt new file mode 100644 index 000000000..44053c380 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/modifiers/ClickableWithRipple.kt @@ -0,0 +1,25 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.internal.modifiers + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor + +@Composable +internal fun Modifier.clickableWithRipple( + role: Role = Role.Button, + color: Color = SurfaceColor.Primary, + onClick: () -> Unit, +): Modifier = this.then( + Modifier.clickable( + role = role, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = color), + onClick = onClick, + ), +) diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/model/LocationItemModel.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/model/LocationItemModel.kt new file mode 100644 index 000000000..90c171a1d --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/model/LocationItemModel.kt @@ -0,0 +1,46 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.model + +sealed class LocationItemModel( + val title: String, + val subtitle: String, + val latitude: Double, + val longitude: Double, +) { + /** + * UiModel used for Location Items which are stored in cache or local database. + * @param storedTitle: the label to display. + * @param storedSubtitle: the subtitle to display. + * @param storedLatitude: the latitude of the location. + * @param storedLongitude the longitude of the location. + */ + data class StoredResult( + private val storedTitle: String, + private val storedSubtitle: String, + private val storedLatitude: Double, + private val storedLongitude: Double, + ) : LocationItemModel( + title = storedTitle, + subtitle = storedSubtitle, + latitude = storedLatitude, + longitude = storedLongitude, + ) + + /** + * UiModel used for Location Items which are provided by external apis. + * @param searchedTitle: the label to display. + * @param searchedSubtitle: the subtitle to display. + * @param searchedLatitude: the latitude of the location. + * @param searchedLongitude the longitude of the location. + */ + data class SearchResult( + private val searchedTitle: String, + private val searchedSubtitle: String, + private val searchedLatitude: Double, + private val searchedLongitude: Double, + ) : LocationItemModel( + title = searchedTitle, + subtitle = searchedSubtitle, + latitude = searchedLatitude, + longitude = searchedLongitude, + ) +} diff --git a/designsystem/src/commonMain/resources/values-es/strings.xml b/designsystem/src/commonMain/resources/values-es/strings.xml index 60a7045a4..a0b90142e 100644 --- a/designsystem/src/commonMain/resources/values-es/strings.xml +++ b/designsystem/src/commonMain/resources/values-es/strings.xml @@ -42,4 +42,8 @@ Formato de fecha incorrecto Formato de hora incorrecto No se muestran todas las opciones.\n Busca para ver más. + Buscar localización + Seleccionar en el mapa + No hay resultados recientes + No hay resultados diff --git a/designsystem/src/commonMain/resources/values/strings.xml b/designsystem/src/commonMain/resources/values/strings.xml index fcfa8e63c..b012b6a6b 100644 --- a/designsystem/src/commonMain/resources/values/strings.xml +++ b/designsystem/src/commonMain/resources/values/strings.xml @@ -42,4 +42,8 @@ Incorrect date format Incorrect time format Not all options are displayed.\n Search to see more. + Search location + Select in map + No recent results + No results diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBarTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBarTest.kt new file mode 100644 index 000000000..0076c3bd5 --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBarTest.kt @@ -0,0 +1,214 @@ +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.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyDescendant +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.component.model.LocationItemModel +import org.junit.Rule +import org.junit.Test + +class LocationSearchBarTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun shouldDisplaySearchBarWithNoRecentResults() { + rule.setContent { + LocationBar( + currentResults = emptyList(), + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = {}, + onLocationSelected = {}, + ) + } + + with(rule) { + onNodeWithTag("SEARCH_BAR_BUTTON") + .assertIsDisplayed() + .performClick() + onNodeWithTag("SEARCH_INPUT") + .assertIsDisplayed() + onNodeWithTag("NO_RECENT_RESULTS") + .assertIsDisplayed() + } + } + + @Test + fun shouldDisplaySearchBarWithResults() { + rule.setContent { + LocationBar( + currentResults = listOf( + LocationItemModel.StoredResult( + "title", + "subtitle", + 0.0, + 0.0, + ), + ), + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = {}, + onLocationSelected = {}, + ) + } + + with(rule) { + onNodeWithTag("SEARCH_BAR_BUTTON") + .assertIsDisplayed() + .performClick() + onNodeWithTag("SEARCH_INPUT") + .assertIsDisplayed() + onNodeWithTag("LOCATION_ITEM_0") + .assertIsDisplayed() + } + } + + @Test + fun shouldDisplayNoResultsMessage() { + rule.setContent { + var items: List by remember { + mutableStateOf( + listOf( + LocationItemModel.StoredResult( + "title", + "subtitle", + 0.0, + 0.0, + ), + ), + ) + } + LocationBar( + currentResults = items, + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = { querySearch -> + items = listOf() + }, + onLocationSelected = {}, + ) + } + + with(rule) { + onNodeWithTag("SEARCH_BAR_BUTTON") + .assertIsDisplayed() + .performClick() + onNodeWithTag("SEARCH_INPUT") + .assertIsDisplayed() + .performTextInput("Hospital la") + waitForIdle() + onNodeWithTag("NO_RESULTS") + .assertIsDisplayed() + } + } + + @Test + fun shouldDisplaySearchResults() { + rule.setContent { + var items: List by remember { + mutableStateOf( + listOf( + LocationItemModel.StoredResult( + "title", + "subtitle", + 0.0, + 0.0, + ), + ), + ) + } + + LocationBar( + currentResults = items, + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = { + items = listOf( + LocationItemModel.SearchResult( + "title search result", + "subtitle search result", + 0.0, + 0.0, + ), + ) + }, + onLocationSelected = {}, + ) + } + + with(rule) { + onNodeWithTag("SEARCH_BAR_BUTTON") + .assertIsDisplayed() + .performClick() + onNodeWithTag("SEARCH_INPUT") + .assertIsDisplayed() + .performTextInput("Hospital la") + waitForIdle() + onNodeWithTag("LOCATION_ITEM_0", useUnmergedTree = true) + .assertIsDisplayed() + .assert(hasAnyDescendant(hasText("title search result"))) + } + } + + @Test + fun shouldDisplaySelectedLocationInfo() { + rule.setContent { + var items: List by remember { + mutableStateOf( + listOf( + LocationItemModel.StoredResult( + "title", + "subtitle", + 0.0, + 0.0, + ), + ), + ) + } + + LocationBar( + currentResults = items, + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = { + items = listOf( + LocationItemModel.SearchResult( + "title search result", + "subtitle search result", + 0.0, + 0.0, + ), + ) + }, + onLocationSelected = {}, + ) + } + + with(rule) { + onNodeWithTag("SEARCH_BAR_BUTTON") + .assertIsDisplayed() + .performClick() + onNodeWithTag("SEARCH_INPUT") + .assertIsDisplayed() + .performTextInput("Hospital la") + waitForIdle() + onNodeWithTag("LOCATION_ITEM_0") + .assertIsDisplayed() + .performClick() + onNodeWithTag("SEARCH_BAR_BUTTON", useUnmergedTree = true) + .assertIsDisplayed() + .assert(hasAnyDescendant(hasText("title search result"))) + } + } +} diff --git a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarButtonSnapshotTest_launchSearchBarButtonTest.png b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarButtonSnapshotTest_launchSearchBarButtonTest.png new file mode 100644 index 000000000..695f102bd --- /dev/null +++ b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarButtonSnapshotTest_launchSearchBarButtonTest.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a4fa9a4ae1c48ec75abfe77699bfcf81c80f57a816f62c6442974c236d3a286 +size 11447 diff --git a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarSearchSnapshotTest_launchSearchBarButtonTest.png b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarSearchSnapshotTest_launchSearchBarButtonTest.png new file mode 100644 index 000000000..f97258c81 --- /dev/null +++ b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarSearchSnapshotTest_launchSearchBarButtonTest.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ff26d23a3074d910416255857ce4a84cc845c321d5da58bcb72ba75a5fe5868 +size 25199