feat: select configured devices in scene editor
This commit is contained in:
+59
-41
@@ -81,6 +81,8 @@ import com.flagship.abox.manager.domain.SceneIcon
|
||||
import com.flagship.abox.manager.domain.SceneId
|
||||
import com.flagship.abox.manager.domain.SceneTrigger
|
||||
import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import com.flagship.abox.manager.domain.DeviceType
|
||||
import com.flagship.abox.manager.domain.DeviceDescriptor
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||
|
||||
@@ -569,6 +571,7 @@ private fun SceneEditorContent(
|
||||
|
||||
if (showTriggerDialog) {
|
||||
AddTriggerDialog(
|
||||
availableDevices = state.availableDevices,
|
||||
onDismiss = { showTriggerDialog = false },
|
||||
onAdd = { triggerType, devName, time, temp, hum, op ->
|
||||
when (triggerType) {
|
||||
@@ -583,6 +586,7 @@ private fun SceneEditorContent(
|
||||
|
||||
if (showConditionDialog) {
|
||||
AddConditionDialog(
|
||||
availableDevices = state.availableDevices,
|
||||
onDismiss = { showConditionDialog = false },
|
||||
onAddTemp = { devName, op, temp ->
|
||||
viewModel.addTemperatureCondition(devName, op, temp)
|
||||
@@ -606,6 +610,7 @@ private fun SceneEditorContent(
|
||||
if (showActionDialog) {
|
||||
AddActionDialog(
|
||||
availableRemotes = state.availableRemotes,
|
||||
availableDevices = state.availableDevices,
|
||||
onDismiss = { showActionDialog = false },
|
||||
viewModel = viewModel,
|
||||
onAddSocket = { name, power ->
|
||||
@@ -767,6 +772,7 @@ private fun SceneAction.describe(): String = when (this) {
|
||||
|
||||
@Composable
|
||||
private fun AddTriggerDialog(
|
||||
availableDevices: List<DeviceDescriptor>,
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (type: Int, deviceName: String, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit,
|
||||
) {
|
||||
@@ -779,7 +785,11 @@ private fun AddTriggerDialog(
|
||||
|
||||
val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") }
|
||||
val isTimeValid = timeRegex.matches(timeInput.trim())
|
||||
val isDeviceValid = selectedType == 0 || deviceName.isNotBlank()
|
||||
val matchingDevices = availableDevices.filter {
|
||||
it.type == if (selectedType == 1 || selectedType == 2) DeviceType.TEMPERATURE_HUMIDITY else DeviceType.SOCKET
|
||||
}
|
||||
val isDeviceValid = selectedType == 0 && isTimeValid || selectedType != 0 &&
|
||||
matchingDevices.any { it.name.value == deviceName }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -806,13 +816,16 @@ private fun AddTriggerDialog(
|
||||
)
|
||||
}
|
||||
1, 2 -> {
|
||||
OutlinedTextField(
|
||||
value = deviceName,
|
||||
onValueChange = { deviceName = it },
|
||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(stringResource(R.string.scene_device_name_label))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
matchingDevices.forEach { device ->
|
||||
FilterChip(
|
||||
selected = deviceName == device.name.value,
|
||||
onClick = { deviceName = device.name.value },
|
||||
label = { Text(device.name.value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
||||
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
||||
@@ -845,6 +858,7 @@ private fun AddTriggerDialog(
|
||||
|
||||
@Composable
|
||||
private fun AddConditionDialog(
|
||||
availableDevices: List<DeviceDescriptor>,
|
||||
onDismiss: () -> Unit,
|
||||
onAddTemp: (deviceName: String, ComparisonOperator, Double) -> Unit,
|
||||
onAddHumidity: (deviceName: String, ComparisonOperator, Double) -> Unit,
|
||||
@@ -867,10 +881,13 @@ private fun AddConditionDialog(
|
||||
val isEndTimeValid = timeRegex.matches(endTime.trim())
|
||||
val isTimeRangeValid = isStartTimeValid && isEndTimeValid
|
||||
|
||||
val sensors = availableDevices.filter { it.type == DeviceType.TEMPERATURE_HUMIDITY }
|
||||
val sockets = availableDevices.filter { it.type == DeviceType.SOCKET }
|
||||
|
||||
val isDeviceValid = when (selectedType) {
|
||||
0 -> tempDeviceName.isNotBlank()
|
||||
1 -> humDeviceName.isNotBlank()
|
||||
2 -> socketDeviceName.isNotBlank()
|
||||
0 -> sensors.any { it.name.value == tempDeviceName }
|
||||
1 -> sensors.any { it.name.value == humDeviceName }
|
||||
2 -> sockets.any { it.name.value == socketDeviceName }
|
||||
else -> true
|
||||
}
|
||||
|
||||
@@ -887,13 +904,12 @@ private fun AddConditionDialog(
|
||||
}
|
||||
when (selectedType) {
|
||||
0 -> {
|
||||
OutlinedTextField(
|
||||
value = tempDeviceName,
|
||||
onValueChange = { tempDeviceName = it },
|
||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(stringResource(R.string.scene_device_name_label))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
sensors.forEach { device ->
|
||||
FilterChip(selected = tempDeviceName == device.name.value, onClick = { tempDeviceName = device.name.value }, label = { Text(device.name.value) })
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
||||
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
||||
@@ -902,13 +918,12 @@ private fun AddConditionDialog(
|
||||
Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f)
|
||||
}
|
||||
1 -> {
|
||||
OutlinedTextField(
|
||||
value = humDeviceName,
|
||||
onValueChange = { humDeviceName = it },
|
||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(stringResource(R.string.scene_device_name_label))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
sensors.forEach { device ->
|
||||
FilterChip(selected = humDeviceName == device.name.value, onClick = { humDeviceName = device.name.value }, label = { Text(device.name.value) })
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
||||
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
||||
@@ -917,13 +932,12 @@ private fun AddConditionDialog(
|
||||
Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f)
|
||||
}
|
||||
2 -> {
|
||||
OutlinedTextField(
|
||||
value = socketDeviceName,
|
||||
onValueChange = { socketDeviceName = it },
|
||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(stringResource(R.string.scene_device_name_label))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
sockets.forEach { device ->
|
||||
FilterChip(selected = socketDeviceName == device.name.value, onClick = { socketDeviceName = device.name.value }, label = { Text(device.name.value) })
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.scene_target_socket_state))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
|
||||
@@ -982,6 +996,7 @@ private fun AddConditionDialog(
|
||||
@Composable
|
||||
private fun AddActionDialog(
|
||||
availableRemotes: List<VirtualRemote>,
|
||||
availableDevices: List<com.flagship.abox.manager.domain.DeviceDescriptor>,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: SceneEditorViewModel,
|
||||
onAddSocket: (deviceName: String, powerState: SocketPowerState) -> Unit,
|
||||
@@ -994,7 +1009,7 @@ private fun AddActionDialog(
|
||||
val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title)
|
||||
val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg)
|
||||
|
||||
var socketName by remember { mutableStateOf("") }
|
||||
var socketName by remember { mutableStateOf(availableDevices.firstOrNull { it.type == DeviceType.SOCKET }?.name?.value.orEmpty()) }
|
||||
var socketPower by remember { mutableStateOf(SocketPowerState.ON) }
|
||||
|
||||
var selectedRemoteIndex by remember { mutableIntStateOf(0) }
|
||||
@@ -1037,13 +1052,16 @@ private fun AddActionDialog(
|
||||
}
|
||||
when (actionType) {
|
||||
0 -> {
|
||||
OutlinedTextField(
|
||||
value = socketName,
|
||||
onValueChange = { socketName = it },
|
||||
label = { Text(stringResource(R.string.scene_select_socket)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(stringResource(R.string.scene_select_socket))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
availableDevices.filter { it.type == DeviceType.SOCKET }.forEach { device ->
|
||||
FilterChip(
|
||||
selected = socketName == device.name.value,
|
||||
onClick = { socketName = device.name.value },
|
||||
label = { Text(device.name.value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.scene_socket_power_state))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
|
||||
@@ -1102,7 +1120,7 @@ private fun AddActionDialog(
|
||||
},
|
||||
confirmButton = {
|
||||
val canConfirm = when (actionType) {
|
||||
0 -> socketName.isNotBlank()
|
||||
0 -> availableDevices.any { it.type == DeviceType.SOCKET && it.name.value == socketName }
|
||||
1 -> availableRemotes.isNotEmpty() && selectedButtonId.isNotBlank()
|
||||
3 -> notifyTitle.isNotBlank()
|
||||
else -> true
|
||||
|
||||
+22
@@ -6,6 +6,9 @@ import com.flagship.abox.manager.domain.AutomationType
|
||||
import com.flagship.abox.manager.domain.ComparisonOperator
|
||||
import com.flagship.abox.manager.domain.ConcurrencyMode
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DeviceCatalog
|
||||
import com.flagship.abox.manager.domain.DeviceDescriptor
|
||||
import com.flagship.abox.manager.domain.DeviceType
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ExecutionStrategy
|
||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||
@@ -50,6 +53,7 @@ data class SceneEditorState(
|
||||
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
|
||||
val enableLogging: Boolean = true,
|
||||
val availableRemotes: List<VirtualRemote> = emptyList(),
|
||||
val availableDevices: List<DeviceDescriptor> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val isSaving: Boolean = false,
|
||||
val isSaved: Boolean = false,
|
||||
@@ -64,6 +68,7 @@ class SceneEditorViewModel @Inject constructor(
|
||||
private val sceneRepository: SceneRepository,
|
||||
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||
private val profileStore: ProviderProfileStore,
|
||||
private val deviceCatalog: DeviceCatalog,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(SceneEditorState())
|
||||
@@ -74,6 +79,23 @@ class SceneEditorViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
observeAvailableRemotes()
|
||||
observeAvailableDevices()
|
||||
}
|
||||
|
||||
private fun observeAvailableDevices() {
|
||||
viewModelScope.launch {
|
||||
profileStore.observeActiveProfile().collectLatest { profile ->
|
||||
if (profile == null) {
|
||||
_state.update { it.copy(availableDevices = emptyList()) }
|
||||
} else {
|
||||
deviceCatalog.observeDevices(profile.id).collectLatest { result ->
|
||||
if (result is DomainResult.Success) {
|
||||
_state.update { it.copy(availableDevices = result.value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeAvailableRemotes() {
|
||||
|
||||
+9
-7
@@ -20,6 +20,7 @@ import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
|
||||
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
|
||||
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
@@ -39,6 +40,7 @@ class SceneEditorViewModelTest {
|
||||
private val profileStore = FakeProviderProfileStore()
|
||||
private val sceneRepository = FakeSceneRepository()
|
||||
private val virtualRemoteRepository = FakeVirtualRemoteRepository()
|
||||
private val deviceCatalog = FakeDeviceCatalog()
|
||||
private val profileId = ProviderProfileId("local-experience")
|
||||
|
||||
private val existingScene = Scene(
|
||||
@@ -66,7 +68,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun initializeWithNullPreparesNewScene() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(null, AutomationType.MANUAL)
|
||||
|
||||
val state = viewModel.state.value
|
||||
@@ -79,7 +81,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun initializeWithExistingSceneLoadsState() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(existingScene.id)
|
||||
|
||||
// Immediately binds sceneId and enters loading state
|
||||
@@ -101,7 +103,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun addingAndReorderingActionsUpdatesState() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(null, AutomationType.MANUAL)
|
||||
viewModel.updateName("My New Scene")
|
||||
|
||||
@@ -129,7 +131,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun addingTriggersAndConditionsForAutomaticRule() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
||||
viewModel.updateName("Auto Rule")
|
||||
|
||||
@@ -153,7 +155,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun saveNewScenePersistsToRepository() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(null, AutomationType.MANUAL)
|
||||
viewModel.updateName("Brand New Scene")
|
||||
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
|
||||
@@ -170,7 +172,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun automaticSceneWithoutTriggersIsInvalid() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
||||
viewModel.updateName("Auto Without Trigger")
|
||||
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
|
||||
@@ -188,7 +190,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun initializeIsIdempotentAndPreservesDraftState() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(existingScene.id)
|
||||
advanceUntilIdle()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user