From 9d64260e1f17cf8673973dfbf56aae6bca7bd00b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 14 Jul 2023 21:34:05 +0200 Subject: [PATCH] Implement a frontend-like single entity picker (#3653) * Implement a frontend-like single entity picker - Adds a new Composable `SingleEntityPicker` which aims to provide a text input with autocomplete search for entities like the frontend, where you can pick one * Improve filtering to allow suggestions with space, searching for entity IDs - Trim search input so the space automatically added after an IME suggestion is ignored - Also search entity ID but sort those results second, friendly name keeps priority --- .../settings/qs/views/ManageTilesView.kt | 28 ++- .../util/compose/SingleEntityPicker.kt | 160 ++++++++++++++++++ common/src/main/res/values/strings.xml | 2 + 3 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/io/homeassistant/companion/android/util/compose/SingleEntityPicker.kt diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/qs/views/ManageTilesView.kt b/app/src/main/java/io/homeassistant/companion/android/settings/qs/views/ManageTilesView.kt index c18742a11..9cf0bd7bc 100755 --- a/app/src/main/java/io/homeassistant/companion/android/settings/qs/views/ManageTilesView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/qs/views/ManageTilesView.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.sp import io.homeassistant.companion.android.common.R import io.homeassistant.companion.android.settings.qs.ManageTilesViewModel import io.homeassistant.companion.android.util.compose.ServerDropdownButton +import io.homeassistant.companion.android.util.compose.SingleEntityPicker import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -128,25 +129,16 @@ fun ManageTilesView( ) } - Text( - text = stringResource(id = R.string.tile_entity), - fontSize = 15.sp + SingleEntityPicker( + entities = viewModel.sortedEntities, + currentEntity = viewModel.selectedEntityId, + onEntityCleared = { viewModel.selectEntityId("") }, + onEntitySelected = viewModel::selectEntityId, + modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), + label = { Text(stringResource(R.string.tile_entity)) } ) - Box { - OutlinedButton(onClick = { expandedEntity = true }) { - Text(text = viewModel.selectedEntityId) - } - DropdownMenu(expanded = expandedEntity, onDismissRequest = { expandedEntity = false }) { - for (item in viewModel.sortedEntities) { - DropdownMenuItem(onClick = { - viewModel.selectEntityId(item.entityId) - expandedEntity = false - }) { - Text(text = item.entityId, fontSize = 15.sp) - } - } - } - } Row(verticalAlignment = Alignment.CenterVertically) { Text( diff --git a/app/src/main/java/io/homeassistant/companion/android/util/compose/SingleEntityPicker.kt b/app/src/main/java/io/homeassistant/companion/android/util/compose/SingleEntityPicker.kt new file mode 100644 index 000000000..fae8abba2 --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/util/compose/SingleEntityPicker.kt @@ -0,0 +1,160 @@ +package io.homeassistant.companion.android.util.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.ContentAlpha +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExposedDropdownMenuBox +import androidx.compose.material.ExposedDropdownMenuDefaults +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.friendlyName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import io.homeassistant.companion.android.common.R as commonR + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun SingleEntityPicker( + entities: List>, + currentEntity: String?, + onEntityCleared: () -> Unit, + onEntitySelected: (String) -> Unit, + modifier: Modifier = Modifier, + label: @Composable (() -> Unit) = { Text(stringResource(commonR.string.select_entity_to_display)) } +) { + val focusManager = LocalFocusManager.current + + var expanded by remember { mutableStateOf(false) } + var inputValue by remember(currentEntity, entities.size) { + mutableStateOf( + if (currentEntity == null) { + "" + } else { + entities.firstOrNull { it.entityId == currentEntity }?.friendlyName ?: currentEntity + } + ) + } + + var list by remember { mutableStateOf(entities) } + var listTooLarge by remember { mutableStateOf(false) } + LaunchedEffect(entities.size, inputValue) { + list = withContext(Dispatchers.IO) { + val query = inputValue.trim() + val items = if (inputValue.isBlank() || inputValue == (entities.firstOrNull { it.entityId == currentEntity }?.friendlyName ?: currentEntity)) { + entities + } else { + entities.filter { + it.friendlyName.contains(query, ignoreCase = true) || + it.entityId.contains(query.replace(" ", "_"), ignoreCase = true) + } + } + // The amount of items is limited because Compose ExposedDropdownMenu isn't lazy + listTooLarge = items.size > 150 + items.sortedWith( + compareBy( + { !it.friendlyName.startsWith(query, ignoreCase = true) }, + { !it.entityId.split(".")[1].startsWith(query.replace(" ", "_"), ignoreCase = true) }, + { it.friendlyName.lowercase() } + ) + ).take(150) + } + } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = modifier + ) { + TextField( + value = inputValue, + onValueChange = { + expanded = true + inputValue = it + }, + label = label, + trailingIcon = + { + Row { + if (currentEntity?.isNotBlank() == true) { + IconButton(onClick = onEntityCleared, modifier = Modifier.clearAndSetSemantics { }) { + Icon( + Icons.Filled.Clear, + stringResource(commonR.string.search_clear_selection) + ) + } + } + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + if (list.isNotEmpty()) { + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + list.forEach { + DropdownMenuItem( + onClick = { + onEntitySelected(it.entityId) + inputValue = it.friendlyName + focusManager.clearFocus() + expanded = false + }, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + Column { + Text( + text = it.friendlyName, + style = MaterialTheme.typography.body1, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + Text( + text = it.entityId, + style = MaterialTheme.typography.body2, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + if (listTooLarge) { + DropdownMenuItem(onClick = { /* No-op */ }, enabled = false) { + Text( + text = stringResource(commonR.string.search_refine_for_more), + style = MaterialTheme.typography.body2, + fontStyle = FontStyle.Italic + ) + } + } + } + } + } +} diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index b50a9622b..36996164b 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -488,9 +488,11 @@ Scene Scenes Scripts + Clear selection Search icons Search icons (in English) Search notifications + Refine your search to view moreā€¦ Search Results Search sensors Your version of Home Assistant does not protect for recently discovered security vulnerabilities in custom components. Please view the details via the bulletin and update as soon as possible.