diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index c33f1ef..5c6a765 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -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, 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, 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, + availableDevices: List, 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 diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt index 02619dd..4be21f1 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -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 = emptyList(), + val availableDevices: List = 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() { diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt index 3458c11..2a56895 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt @@ -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()