mirror of
https://github.com/home-assistant/android
synced 2024-09-06 17:23:32 +00:00
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
This commit is contained in:
parent
dbae8d2613
commit
9d64260e1f
|
@ -38,6 +38,7 @@ import androidx.compose.ui.unit.sp
|
||||||
import io.homeassistant.companion.android.common.R
|
import io.homeassistant.companion.android.common.R
|
||||||
import io.homeassistant.companion.android.settings.qs.ManageTilesViewModel
|
import io.homeassistant.companion.android.settings.qs.ManageTilesViewModel
|
||||||
import io.homeassistant.companion.android.util.compose.ServerDropdownButton
|
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.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
||||||
|
@ -128,25 +129,16 @@ fun ManageTilesView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
SingleEntityPicker(
|
||||||
text = stringResource(id = R.string.tile_entity),
|
entities = viewModel.sortedEntities,
|
||||||
fontSize = 15.sp
|
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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
@ -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<Entity<*>>,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -488,9 +488,11 @@
|
||||||
<string name="scene">Scene</string>
|
<string name="scene">Scene</string>
|
||||||
<string name="scenes">Scenes</string>
|
<string name="scenes">Scenes</string>
|
||||||
<string name="scripts">Scripts</string>
|
<string name="scripts">Scripts</string>
|
||||||
|
<string name="search_clear_selection">Clear selection</string>
|
||||||
<string name="search_icons">Search icons</string>
|
<string name="search_icons">Search icons</string>
|
||||||
<string name="search_icons_in_english">Search icons (in English)</string>
|
<string name="search_icons_in_english">Search icons (in English)</string>
|
||||||
<string name="search_notifications">Search notifications</string>
|
<string name="search_notifications">Search notifications</string>
|
||||||
|
<string name="search_refine_for_more">Refine your search to view more…</string>
|
||||||
<string name="search_results">Search Results</string>
|
<string name="search_results">Search Results</string>
|
||||||
<string name="search_sensors">Search sensors</string>
|
<string name="search_sensors">Search sensors</string>
|
||||||
<string name="security_vulnerably_message">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.</string>
|
<string name="security_vulnerably_message">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.</string>
|
||||||
|
|
Loading…
Reference in a new issue