-
-
Notifications
You must be signed in to change notification settings - Fork 965
feat(Sensors): Add search filter to sensor setting allow list dialog #6567
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2baef1b
6fdce1a
f015d0c
65d0321
efcacd9
0c07286
ec55766
b38107c
0f35f26
271dca3
28cdedd
5f03e84
9e50c02
1182eee
fee247f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,332 @@ | ||
| package io.homeassistant.companion.android.settings.sensor.views | ||
|
|
||
| import androidx.annotation.VisibleForTesting | ||
| 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 | ||
| import androidx.compose.foundation.layout.Row | ||
| import androidx.compose.foundation.layout.Spacer | ||
| 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 | ||
| import androidx.compose.foundation.layout.width | ||
| import androidx.compose.foundation.lazy.LazyColumn | ||
| import androidx.compose.foundation.lazy.items | ||
| import androidx.compose.material.icons.Icons | ||
| import androidx.compose.material.icons.filled.Search | ||
| import androidx.compose.material3.Checkbox | ||
| import androidx.compose.material3.CircularProgressIndicator | ||
| import androidx.compose.material3.ExperimentalMaterial3Api | ||
| import androidx.compose.material3.Icon | ||
| import androidx.compose.material3.Text | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.runtime.Immutable | ||
| import androidx.compose.runtime.LaunchedEffect | ||
| import androidx.compose.runtime.getValue | ||
| import androidx.compose.runtime.mutableStateListOf | ||
| import androidx.compose.runtime.mutableStateOf | ||
| import androidx.compose.runtime.remember | ||
| import androidx.compose.runtime.setValue | ||
| import androidx.compose.ui.Alignment | ||
| import androidx.compose.ui.Modifier | ||
| import androidx.compose.ui.geometry.Offset | ||
| 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.res.stringResource | ||
| import androidx.compose.ui.text.style.TextAlign | ||
| import androidx.compose.ui.unit.Velocity | ||
| 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.HAModalBottomSheet | ||
| import io.homeassistant.companion.android.common.compose.composable.HAPlainButton | ||
| import io.homeassistant.companion.android.common.compose.composable.HASearchField | ||
| import io.homeassistant.companion.android.common.compose.composable.rememberHAModalBottomSheetState | ||
| 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.settings.sensor.SensorDetailViewModel | ||
| import io.homeassistant.companion.android.util.compose.safeScreenHeight | ||
| import kotlin.coroutines.CoroutineContext | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.withContext | ||
|
|
||
| /** Threshold above which the search field becomes visible to help users navigate long lists. */ | ||
| private const val SEARCH_VISIBILITY_THRESHOLD = 10 | ||
|
|
||
| /** | ||
| * Bottom sheet for multi-select allow list sensor settings (apps, bluetooth, zones, beacons). | ||
| * | ||
| * Renders a search field (when the entry list exceeds [SEARCH_VISIBILITY_THRESHOLD]), a scrollable | ||
| * list of selectable rows, and a fixed footer with cancel and save actions. While the selection | ||
| * state is loading, a centered progress indicator is shown instead of the list. | ||
| * | ||
| * Filtering is performed off the UI thread on [dispatcher] to keep the sheet responsive on long | ||
| * lists. The search field debounces the query so the list does not re-filter on every keystroke. | ||
| * | ||
| * @param title Heading displayed at the top of the sheet. | ||
| * @param state Current dialog state holding the entries, selection and loading flag. | ||
| * @param onDismiss Invoked when the sheet is dismissed without saving. | ||
| * @param onSave Invoked with the updated state when the user confirms the selection. | ||
| * @param modifier Optional [Modifier] applied to the sheet container. | ||
| * @param dispatcher Coroutine context used to compute the filtered entries; injectable for tests. | ||
| */ | ||
| @OptIn(ExperimentalMaterial3Api::class) | ||
| @Composable | ||
| internal fun SensorDetailSettingSheet( | ||
| title: String, | ||
| state: SensorDetailViewModel.Companion.SettingDialogState, | ||
| onDismiss: () -> Unit, | ||
| onSave: (SensorDetailViewModel.Companion.SettingDialogState) -> Unit, | ||
| modifier: Modifier = Modifier, | ||
| dispatcher: CoroutineContext = Dispatchers.Default, | ||
| ) { | ||
| val checkedValue = remember { | ||
| mutableStateListOf<String>().also { it.addAll(state.entriesSelected) } | ||
| } | ||
| var searchQuery by remember { mutableStateOf("") } | ||
| // Wrap the entries once at the composable boundary so the Compose Compiler can infer | ||
| // stability for the `rememberFilteredSettingEntries` parameter (a raw | ||
| // `List<Pair<String, String>>` is treated as unstable). | ||
| val settingEntries = remember(state.entries) { SettingEntries(state.entries) } | ||
| val filteredEntries = rememberFilteredSettingEntries( | ||
| entries = settingEntries, | ||
| searchQuery = searchQuery, | ||
| dispatcher = dispatcher, | ||
| ) | ||
| val showSearch = state.entries.size > SEARCH_VISIBILITY_THRESHOLD | ||
|
|
||
| val bottomSheetState = rememberHAModalBottomSheetState(skipPartiallyExpanded = true) | ||
| val screenHeight = safeScreenHeight() - HADimens.SPACE16 | ||
|
|
||
| val nestedScrollFlingGuard = remember { nestedScrollFlingGuard() } | ||
|
|
||
| HAModalBottomSheet( | ||
| bottomSheetState = bottomSheetState, | ||
| modifier = modifier, | ||
| onDismissRequest = onDismiss, | ||
| ) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Break down the content into multiple smaller composables |
||
| Column( | ||
| modifier = Modifier | ||
| .height(screenHeight) | ||
| .nestedScroll(nestedScrollFlingGuard) | ||
| .pointerInput(Unit) { | ||
| // Consume vertical drag gestures to prevent BottomSheet from interpreting them | ||
| // as collapse gestures while the user scrolls the entry list. | ||
| detectVerticalDragGestures { _, _ -> } | ||
| }, | ||
| ) { | ||
| Text( | ||
| text = title, | ||
| style = HATextStyle.HeadlineMedium.copy(textAlign = TextAlign.Start), | ||
| modifier = Modifier.padding( | ||
| horizontal = HADimens.SPACE4, | ||
| vertical = HADimens.SPACE3, | ||
|
Comment on lines
+128
to
+129
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you could apply this directly to the Column using the horizontal and vertical params. |
||
| ), | ||
| ) | ||
| if (showSearch) { | ||
| HASearchField( | ||
| query = searchQuery, | ||
| onQueryChange = { searchQuery = it }, | ||
| leadingIcon = { | ||
| Icon( | ||
| imageVector = Icons.Default.Search, | ||
| contentDescription = null, | ||
| tint = LocalHAColorScheme.current.colorOnNeutralNormal, | ||
| ) | ||
| }, | ||
| modifier = Modifier | ||
| .fillMaxWidth() | ||
| .padding(horizontal = HADimens.SPACE4), | ||
| ) | ||
| } | ||
| Box( | ||
| modifier = Modifier | ||
| .fillMaxWidth() | ||
| .weight(1f), | ||
| contentAlignment = Alignment.Center, | ||
| ) { | ||
| if (state.loading) { | ||
| CircularProgressIndicator() | ||
| } else { | ||
| LazyColumn(modifier = Modifier.fillMaxWidth()) { | ||
| items(filteredEntries, key = { (id) -> id }) { (id, entry) -> | ||
| BottomSheetSettingRow( | ||
| label = entry, | ||
| checked = checkedValue.contains(id), | ||
| onClick = { isChecked -> | ||
| if (isChecked) { | ||
| if (id !in checkedValue) checkedValue.add(id) | ||
| } else { | ||
| checkedValue.remove(id) | ||
| } | ||
| }, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Row( | ||
| modifier = Modifier | ||
| .fillMaxWidth() | ||
| .padding(horizontal = HADimens.SPACE4, vertical = HADimens.SPACE3), | ||
| horizontalArrangement = Arrangement.End, | ||
| ) { | ||
| HAPlainButton( | ||
| text = stringResource(commonR.string.cancel), | ||
| onClick = onDismiss, | ||
| ) | ||
| Spacer(modifier = Modifier.width(HADimens.SPACE2)) | ||
| HAFilledButton( | ||
| text = stringResource(commonR.string.save), | ||
| enabled = !state.loading, | ||
| onClick = { | ||
| val joinedValue = joinSelectedValues(checkedValue) | ||
| onSave(state.copy(setting = state.setting.copy(value = joinedValue))) | ||
| }, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Immutable wrapper around the raw `List<Pair<String, String>>` setting entries so that | ||
| * Composable parameters can be marked stable by the Compose Compiler. The Compose Compiler | ||
| * cannot infer stability for `List<Pair<String, String>>` directly, which forces unnecessary | ||
| * recompositions. | ||
| */ | ||
| @Immutable | ||
| internal data class SettingEntries(val items: List<Pair<String, String>>) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's not ever work with a Pair<String, String> it is not usable if you don't know how it has been built. No need to build a wrapper arround the list for the immutable annotation, for now ignore the lint warning. We should one day use Immutable containers but we are not yet ready for it. |
||
|
|
||
| /** | ||
| * Computes the filtered entries off the UI thread. | ||
| * | ||
| * Re-runs whenever [entries] or [searchQuery] change, dispatching the filter work onto | ||
| * [dispatcher] so long lists do not freeze the sheet. | ||
| */ | ||
| @Composable | ||
| private fun rememberFilteredSettingEntries( | ||
| entries: SettingEntries, | ||
| searchQuery: String, | ||
| dispatcher: CoroutineContext = Dispatchers.Default, | ||
| ): List<Pair<String, String>> { | ||
| var filtered by remember(entries) { mutableStateOf(entries.items) } | ||
|
|
||
| LaunchedEffect(entries, searchQuery) { | ||
| filtered = withContext(dispatcher) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's not bother with a parameters for the Dispatcher here and always use Default. |
||
| filterSettingEntries(entries.items, searchQuery) | ||
| } | ||
| } | ||
|
|
||
| return filtered | ||
| } | ||
|
|
||
| /** | ||
| * Filters setting entries by matching the query against entry labels (case-insensitive). | ||
| * Returns all entries when the query is blank. | ||
| */ | ||
| @VisibleForTesting | ||
| internal fun filterSettingEntries(entries: List<Pair<String, String>>, query: String): List<Pair<String, String>> { | ||
| val trimmed = query.trim() | ||
| return if (trimmed.isBlank()) { | ||
| entries | ||
| } else { | ||
| entries.filter { (_, label) -> label.contains(trimmed, ignoreCase = true) } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Joins selected entry IDs into the comma-separated string format expected by [SensorDetailViewModel]. | ||
| * | ||
| * Mirrors the legacy formatting used elsewhere in the sensor settings code, removing list bracket | ||
| * artifacts that originate from older Java-style toString conversions. | ||
| */ | ||
| internal fun joinSelectedValues(values: List<String>): String { | ||
| return values.joinToString().replace("[", "").replace("]", "") | ||
| } | ||
|
|
||
| /** | ||
| * Splits a sensor setting label into a primary line and an optional secondary line. | ||
| * | ||
| * Sensor allow-list entries are formatted as `"<primary>\n(<secondary>)"` where the secondary | ||
| * line is wrapped in parentheses (for example `"Chrome\n(com.google.chrome)"`). Only the first | ||
| * newline is used to separate the two parts; any further newlines remain inside the secondary | ||
| * value. Surrounding parentheses are stripped from the secondary value when, and only when, both | ||
| * the leading `(` and trailing `)` are present, matching [String.removeSurrounding] semantics. | ||
| * | ||
| * Shared between [SensorDetailSettingSheet] (the new bottom sheet) and the legacy Material 2 | ||
| * dialog row in `SensorDetailView` so both render identical primary/secondary text. | ||
| * | ||
| * @return a pair of `(primary, secondary)` where `secondary` is `null` for single-line labels. | ||
| */ | ||
| internal fun parseSettingLabel(label: String): Pair<String, String?> { | ||
| val parts = label.split("\n", limit = 2) | ||
| val primaryText = parts[0] | ||
| val secondaryText = parts.getOrNull(1)?.removeSurrounding("(", ")") | ||
| return primaryText to secondaryText | ||
| } | ||
|
|
||
| /** | ||
| * Returns a [NestedScrollConnection] that absorbs fling velocity and post-scroll offsets to prevent | ||
| * the bottom sheet from collapsing while the user scrolls or flings the entry list. | ||
| */ | ||
| private fun nestedScrollFlingGuard(): NestedScrollConnection { | ||
| return object : NestedScrollConnection { | ||
| override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = available | ||
|
|
||
| override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = available | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Single selectable row used inside [SensorDetailSettingSheet]. Built with Material 3 components and | ||
| * the project's design tokens; intentionally separate from [SensorDetailSettingRow] used by the | ||
| * legacy Material 2 dialog. | ||
| */ | ||
| @Composable | ||
| private fun BottomSheetSettingRow( | ||
| label: String, | ||
| checked: Boolean, | ||
| onClick: (Boolean) -> Unit, | ||
| modifier: Modifier = Modifier, | ||
| ) { | ||
| val (primaryText, secondaryText) = parseSettingLabel(label) | ||
| val colorScheme = LocalHAColorScheme.current | ||
|
|
||
| Row( | ||
| modifier = modifier | ||
| .fillMaxWidth() | ||
| .clickable { onClick(!checked) } | ||
| .padding(horizontal = HADimens.SPACE3) | ||
| .heightIn(min = HADimens.SPACE16), | ||
| verticalAlignment = Alignment.CenterVertically, | ||
| ) { | ||
| Checkbox( | ||
| checked = checked, | ||
| onCheckedChange = null, | ||
| modifier = Modifier.size(width = HADimens.SPACE12, height = HADimens.SPACE12), | ||
| ) | ||
| Column(modifier = Modifier.weight(1f)) { | ||
| Text( | ||
| text = primaryText, | ||
| style = HATextStyle.Body.copy( | ||
| textAlign = TextAlign.Start, | ||
| color = colorScheme.colorTextPrimary, | ||
| ), | ||
| ) | ||
| if (secondaryText != null) { | ||
| Spacer(Modifier.height(HADimens.SPACE1)) | ||
| Text( | ||
| text = secondaryText, | ||
| style = HATextStyle.BodyMedium.copy(textAlign = TextAlign.Start), | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.