Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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.animateContentSize
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.Row
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.Checkbox
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.SwitchDefaults
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.runtime.setValue
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.ImeAction
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.sensor.SensorDetailViewModel
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 @@ fun SensorDetailView(
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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should add a HATheme here and add a todo tight to #6839

title = viewModel.getSettingTranslatedTitle(dialogState.setting.name),
state = dialogState,
onDismiss = { viewModel.cancelSettingWithDialog() },
onSave = { updatedState -> onDialogSettingSubmitted(updatedState) },
)
Comment thread
danielgomezrico marked this conversation as resolved.
} else {
SensorDetailSettingDialog(
viewModel = viewModel,
state = dialogState,
onDismiss = { viewModel.cancelSettingWithDialog() },
onSubmit = { state -> onDialogSettingSubmitted(state) },
)
}
}
}
LazyColumn(
Expand Down Expand Up @@ -734,6 +771,146 @@ fun SensorDetailUpdateInfoDialog(
)
}

/**
* 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,
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
) {
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, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
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>>, query: String): List<Pair<String, String>> {
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
Comment thread
danielgomezrico marked this conversation as resolved.
Outdated
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 +919,15 @@ fun SensorDetailSettingRow(
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 +944,21 @@ fun SensorDetailSettingRow(
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)
}
}