Skip to content

Commit 2baef1b

Browse files
feat(sensors): add search filter to sensor setting allow list dialog
Add a search TextField to `SensorDetailSettingDialog` so users can quickly find apps in long lists (e.g., Last Notification Allow List). Filtering is local to the composable; selections persist across searches.
1 parent b1d9d20 commit 2baef1b

2 files changed

Lines changed: 108 additions & 2 deletions

File tree

app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/views/SensorDetailView.kt

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import androidx.compose.foundation.text.selection.SelectionContainer
2828
import androidx.compose.material.Card
2929
import androidx.compose.material.Checkbox
3030
import androidx.compose.material.CircularProgressIndicator
31+
import androidx.compose.material.Icon
32+
import androidx.compose.material.IconButton
3133
import androidx.compose.material.ContentAlpha
3234
import androidx.compose.material.Divider
3335
import androidx.compose.material.LocalContentAlpha
@@ -42,6 +44,9 @@ import androidx.compose.material.SwitchDefaults
4244
import androidx.compose.material.Text
4345
import androidx.compose.material.TextField
4446
import androidx.compose.material.contentColorFor
47+
import androidx.compose.material.icons.Icons
48+
import androidx.compose.material.icons.filled.Clear
49+
import androidx.compose.material.icons.filled.Search
4550
import androidx.compose.material.rememberScaffoldState
4651
import androidx.compose.runtime.Composable
4752
import androidx.compose.runtime.CompositionLocalProvider
@@ -608,8 +613,35 @@ fun SensorDetailSettingDialog(
608613
CircularProgressIndicator()
609614
}
610615
} else if (listSettingDialog) {
611-
LazyColumn {
612-
items(state.entries, key = { (id) -> id }) { (id, entry) ->
616+
var searchQuery by remember { mutableStateOf("") }
617+
val filteredEntries = remember(state.entries, searchQuery) {
618+
filterSettingEntries(state.entries, searchQuery)
619+
}
620+
Column {
621+
TextField(
622+
value = searchQuery,
623+
onValueChange = { searchQuery = it },
624+
modifier = Modifier
625+
.fillMaxWidth()
626+
.padding(horizontal = 16.dp),
627+
singleLine = true,
628+
label = { Text(stringResource(commonR.string.search)) },
629+
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
630+
trailingIcon = if (searchQuery.isNotBlank()) {
631+
{
632+
IconButton(onClick = { searchQuery = "" }) {
633+
Icon(
634+
Icons.Filled.Clear,
635+
contentDescription = stringResource(commonR.string.clear_search),
636+
)
637+
}
638+
}
639+
} else {
640+
null
641+
},
642+
)
643+
LazyColumn(modifier = Modifier.weight(1f)) {
644+
items(filteredEntries, key = { (id) -> id }) { (id, entry) ->
613645
SensorDetailSettingRow(
614646
label = entry,
615647
checked = if (state.setting.valueType ==
@@ -634,6 +666,7 @@ fun SensorDetailSettingDialog(
634666
},
635667
)
636668
}
669+
}
637670
}
638671
} else {
639672
TextField(
@@ -734,6 +767,17 @@ fun SensorDetailUpdateInfoDialog(
734767
)
735768
}
736769

770+
/**
771+
* Filters setting entries by matching the query against entry labels (case-insensitive).
772+
* Returns all entries when the query is blank.
773+
*/
774+
internal fun filterSettingEntries(
775+
entries: List<Pair<String, String>>,
776+
query: String,
777+
): List<Pair<String, String>> =
778+
if (query.isBlank()) entries
779+
else entries.filter { (_, label) -> label.contains(query.trim(), ignoreCase = true) }
780+
737781
@Composable
738782
fun SensorDetailSettingRow(
739783
label: String,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package io.homeassistant.companion.android.settings.sensor.views
2+
3+
import org.junit.jupiter.api.Assertions.assertEquals
4+
import org.junit.jupiter.api.Test
5+
6+
class SensorDetailSettingDialogFilterTest {
7+
8+
private val entries = listOf(
9+
"com.google.chrome" to "Chrome\n(com.google.chrome)",
10+
"org.mozilla.firefox" to "Firefox\n(org.mozilla.firefox)",
11+
"com.example.app" to "Example App\n(com.example.app)",
12+
)
13+
14+
@Test
15+
fun `Given empty query when filtering then return all entries`() {
16+
val result = filterSettingEntries(entries, query = "")
17+
18+
assertEquals(entries, result)
19+
}
20+
21+
@Test
22+
fun `Given blank query when filtering then return all entries`() {
23+
val result = filterSettingEntries(entries, query = " ")
24+
25+
assertEquals(entries, result)
26+
}
27+
28+
@Test
29+
fun `Given query matching app name when filtering then return matching entries`() {
30+
val result = filterSettingEntries(entries, query = "Chrome")
31+
32+
assertEquals(listOf(entries[0]), result)
33+
}
34+
35+
@Test
36+
fun `Given query matching package name in label when filtering then return matching entries`() {
37+
val result = filterSettingEntries(entries, query = "com.google")
38+
39+
assertEquals(listOf(entries[0]), result)
40+
}
41+
42+
@Test
43+
fun `Given case-insensitive query when filtering then return matches`() {
44+
val result = filterSettingEntries(entries, query = "CHROME")
45+
46+
assertEquals(listOf(entries[0]), result)
47+
}
48+
49+
@Test
50+
fun `Given query matching no entries when filtering then return empty list`() {
51+
val result = filterSettingEntries(entries, query = "nonexistent")
52+
53+
assertEquals(emptyList<Pair<String, String>>(), result)
54+
}
55+
56+
@Test
57+
fun `Given query with leading and trailing spaces when filtering then trim and match`() {
58+
val result = filterSettingEntries(entries, query = " Chrome ")
59+
60+
assertEquals(listOf(entries[0]), result)
61+
}
62+
}

0 commit comments

Comments
 (0)