Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2baef1b
feat(sensors): add search filter to sensor setting allow list dialog
danielgomezrico Mar 12, 2026
6fdce1a
refactor(sensors): extract search field composable and hide for small…
danielgomezrico Mar 13, 2026
f015d0c
Update dialog
danielgomezrico Mar 24, 2026
65d0321
fix(sensors): sort imports in lexicographic order for ktlint
danielgomezrico Mar 29, 2026
efcacd9
chore(UI): Use styles to present better data on the UI
danielgomezrico Mar 29, 2026
0c07286
style(Sensors): Add braces to if/else in filterSettingEntries
danielgomezrico Mar 29, 2026
ec55766
style(Sensors): Convert short function signatures to single-line format
danielgomezrico Apr 2, 2026
b38107c
Cleanup
danielgomezrico May 2, 2026
0f35f26
refactor(Sensors): address review feedback on multi-select bottom sheet
danielgomezrico May 3, 2026
271dca3
test(Sensors): rename filter test to match SensorDetailSettingSheet
danielgomezrico May 3, 2026
28cdedd
test(Sensors): cover joinSelectedValues, label parsing, and dialog st…
danielgomezrico May 3, 2026
5f03e84
test(common): cover HASearchField debounce behaviour
danielgomezrico May 3, 2026
9e50c02
Merge remote-tracking branch 'origin/main' into feature/search-sensor…
danielgomezrico May 4, 2026
1182eee
style(common): collapse debouncedSearchUpdate signature to single line
danielgomezrico May 4, 2026
fee247f
fix(Sensors): satisfy Compose stability lint and drop misplaced @Visi…
danielgomezrico May 4, 2026
15e0d85
refactor(Sensors): tidy SensorDetailSettingSheet per PR feedback
danielgomezrico May 17, 2026
0d6f062
refactor(Sensors): wrap multi-select sheet in HATheme
danielgomezrico May 17, 2026
e1de5eb
fix(Sensors): wrap SettingEntry list in @Immutable holder for Compose…
danielgomezrico May 18, 2026
e41294c
fix(Sensors): top-align entry list in allow-list sheet
danielgomezrico May 19, 2026
1ccc76f
refactor(Sensors): address review feedback on multi-select bottom sheet
danielgomezrico May 19, 2026
81d5992
refactor(common): expose HASearchField debounce constant for tests
danielgomezrico May 19, 2026
4c963a3
refactor(common): extract bottom-sheet scroll/fling guard into shared…
danielgomezrico May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand All @@ -14,6 +15,7 @@
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
Expand All @@ -30,6 +32,8 @@
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.ContentAlpha
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton
Expand All @@ -42,7 +46,12 @@
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.contentColorFor
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.rememberScaffoldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
Expand All @@ -56,7 +65,12 @@
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.colorResource
Expand All @@ -66,13 +80,20 @@
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.compose.Image
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.compose.composable.HAFilledButton
import io.homeassistant.companion.android.common.compose.composable.HAHint
import io.homeassistant.companion.android.common.compose.composable.HAModalBottomSheet
import io.homeassistant.companion.android.common.compose.composable.HAPlainButton
import io.homeassistant.companion.android.common.compose.theme.HADimens
import io.homeassistant.companion.android.common.compose.theme.HATextStyle
import io.homeassistant.companion.android.common.compose.theme.LocalHAColorScheme
import io.homeassistant.companion.android.common.sensors.SensorManager
import io.homeassistant.companion.android.common.util.kotlinJsonMapper
import io.homeassistant.companion.android.database.sensor.SensorSetting
Expand All @@ -84,6 +105,7 @@
import io.homeassistant.companion.android.settings.views.SettingsSubheader
import io.homeassistant.companion.android.util.compose.MdcAlertDialog
import io.homeassistant.companion.android.util.compose.TransparentChip
import io.homeassistant.companion.android.util.compose.safeScreenHeight
import io.homeassistant.companion.android.util.safeBottomPaddingValues
import io.homeassistant.companion.android.util.safeBottomWindowInsets
import kotlinx.coroutines.flow.launchIn
Expand Down Expand Up @@ -160,13 +182,28 @@
onDismiss = { sensorUpdateTypeInfo = false },
)
} else {
viewModel.sensorSettingsDialog?.let {
SensorDetailSettingDialog(
viewModel = viewModel,
state = it,
onDismiss = { viewModel.cancelSettingWithDialog() },
onSubmit = { state -> onDialogSettingSubmitted(state) },
viewModel.sensorSettingsDialog?.let { dialogState ->
val isMultiSelectList = dialogState.setting.valueType in listOf(
SensorSettingType.LIST_APPS,
SensorSettingType.LIST_BLUETOOTH,
SensorSettingType.LIST_ZONES,
SensorSettingType.LIST_BEACONS,
)
if (isMultiSelectList && !dialogState.loading) {
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
SensorDetailSettingSheet(
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
title = viewModel.getSettingTranslatedTitle(dialogState.setting.name),
state = dialogState,
onDismiss = { viewModel.cancelSettingWithDialog() },
onSave = { updatedState -> onDialogSettingSubmitted(updatedState) },
)
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
} else {
SensorDetailSettingDialog(
viewModel = viewModel,
state = dialogState,
onDismiss = { viewModel.cancelSettingWithDialog() },
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
onSubmit = { state -> onDialogSettingSubmitted(state) },
)
}
}
}
LazyColumn(
Expand Down Expand Up @@ -717,9 +754,9 @@
}

SensorManager.BasicSensor.UpdateType.LOCATION -> stringResource(
commonR.string.sensor_update_type_info_location,
)

SensorManager.BasicSensor.UpdateType.CUSTOM -> stringResource(
commonR.string.sensor_update_type_info_custom,
)
Expand All @@ -734,6 +771,153 @@
)
}

/**
* Bottom sheet for multi-select allow list sensor settings (apps, bluetooth, zones, beacons).
* Uses [HAModalBottomSheet] following the Entity picker pattern with search, checkboxes,
* and cancel/save actions.
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SensorDetailSettingSheet(
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
title: String,
state: SensorDetailViewModel.Companion.SettingDialogState,
onDismiss: () -> Unit,
onSave: (SensorDetailViewModel.Companion.SettingDialogState) -> Unit,
modifier: Modifier = Modifier,
) {
val checkedValue = remember {
mutableStateListOf<String>().also { it.addAll(state.entriesSelected) }
}
var searchQuery by remember { mutableStateOf("") }
val filteredEntries = remember(state.entries, searchQuery) {
filterSettingEntries(state.entries, searchQuery)
}
val showSearch = state.entries.size > 10
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated

val bottomSheetState = rememberStandardBottomSheetState(skipHiddenState = false)
val screenHeight = safeScreenHeight() - HADimens.SPACE16
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated

val consumeFlingNestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset =
available

override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = available
}
}
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated

HAModalBottomSheet(
bottomSheetState = bottomSheetState,
modifier = modifier,
onDismissRequest = onDismiss,
) {
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
Column(
modifier = Modifier
.height(screenHeight)
.nestedScroll(consumeFlingNestedScrollConnection)
.pointerInput(Unit) {
detectVerticalDragGestures { _, _ -> }
},
) {
Text(
text = title,
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(
horizontal = HADimens.SPACE4,
vertical = HADimens.SPACE3,
),
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
)
if (showSearch) {
SettingSearchField(
query = searchQuery,
onQueryChange = { searchQuery = it },
)
}
LazyColumn(modifier = Modifier.weight(1f)) {
items(filteredEntries, key = { (id) -> id }) { (id, entry) ->
SensorDetailSettingRow(
label = entry,
checked = checkedValue.contains(id),
multiple = true,
onClick = { isChecked ->
if (checkedValue.contains(id) && !isChecked) {
checkedValue.remove(id)
} else if (!checkedValue.contains(id) && isChecked) {
checkedValue.add(id)
}
},
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = HADimens.SPACE4, vertical = HADimens.SPACE3),
horizontalArrangement = Arrangement.End,
) {
HAPlainButton(
text = stringResource(android.R.string.cancel),
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
onClick = onDismiss,
)
Spacer(modifier = Modifier.width(HADimens.SPACE2))
HAFilledButton(
text = stringResource(commonR.string.save),
onClick = {
val joinedValue = checkedValue.joinToString().replace("[", "").replace("]", "")
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
onSave(state.copy().apply { setting.value = joinedValue })
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
},
)
}
}
}
}

@Composable
private fun SettingSearchField(
query: String,
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
onQueryChange: (String) -> Unit,
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
modifier: Modifier = Modifier,
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
) {
TextField(
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
value = query,
onValueChange = onQueryChange,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
singleLine = true,
label = { Text(stringResource(commonR.string.search)) },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
trailingIcon = if (query.isNotBlank()) {
{
IconButton(onClick = { onQueryChange("") }) {
Icon(
Icons.Filled.Clear,
contentDescription = stringResource(commonR.string.clear_search),
)
}
}
} else {
null
},
)
}

/**
* Filters setting entries by matching the query against entry labels (case-insensitive).
* Returns all entries when the query is blank.
*/
internal fun filterSettingEntries(
entries: List<Pair<String, String>>,
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
query: String,
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
Comment thread
danielgomezrico marked this conversation as resolved.
Fixed
): List<Pair<String, String>> {
val trimmed = query.trim()
return if (trimmed.isBlank()) {
entries
} else {
entries.filter { (_, label) -> label.contains(trimmed, ignoreCase = true) }
}
}

@Composable
fun SensorDetailSettingRow(
label: String,
Expand All @@ -742,10 +926,15 @@
onClick: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val parts = label.split("\n", limit = 2)
val primaryText = parts[0]
val secondaryText = parts.getOrNull(1)?.removeSurrounding("(", ")")

Row(
modifier = modifier
.clickable { onClick(!checked) }
.padding(horizontal = 12.dp)
.heightIn(min = 64.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Expand All @@ -762,6 +951,21 @@
modifier = Modifier.size(width = 48.dp, height = 48.dp),
)
}
Text(label)
Column(modifier = Modifier.weight(1f)) {
Text(
text = primaryText,
style = HATextStyle.Body.copy(
textAlign = TextAlign.Start,
color = LocalHAColorScheme.current.colorTextPrimary,
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
),
)
if (secondaryText != null) {
Spacer(Modifier.height(HADimens.SPACE1))
Text(
text = secondaryText,
style = HATextStyle.BodyMedium.copy(textAlign = TextAlign.Start),
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package io.homeassistant.companion.android.settings.sensor.views

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class SensorDetailSettingDialogFilterTest {

private val entries = listOf(
"com.google.chrome" to "Chrome\n(com.google.chrome)",
"org.mozilla.firefox" to "Firefox\n(org.mozilla.firefox)",
"com.example.app" to "Example App\n(com.example.app)",
)

@Test
fun `Given empty query when filtering then return all entries`() {
val result = filterSettingEntries(entries, query = "")

assertEquals(entries, result)
}

@Test
fun `Given blank query when filtering then return all entries`() {
val result = filterSettingEntries(entries, query = " ")

assertEquals(entries, result)
}

@Test
fun `Given query matching app name when filtering then return matching entries`() {
val result = filterSettingEntries(entries, query = "Chrome")

assertEquals(listOf(entries[0]), result)
}

@Test
fun `Given query matching package name in label when filtering then return matching entries`() {
val result = filterSettingEntries(entries, query = "com.google")

assertEquals(listOf(entries[0]), result)
}

@Test
fun `Given case-insensitive query when filtering then return matches`() {
val result = filterSettingEntries(entries, query = "CHROME")

assertEquals(listOf(entries[0]), result)
}

@Test
fun `Given query matching no entries when filtering then return empty list`() {
val result = filterSettingEntries(entries, query = "nonexistent")

assertEquals(emptyList<Pair<String, String>>(), result)
}

@Test
fun `Given query with leading and trailing spaces when filtering then trim and match`() {
val result = filterSettingEntries(entries, query = " Chrome ")

assertEquals(listOf(entries[0]), result)
}
}
Loading