feat(ui): complete ABox app experience redesign #10

Merged
KremeCN merged 40 commits from feat/ui-ux-redesign into main 2026-08-27 23:25:08 +08:00
3 changed files with 90 additions and 48 deletions
Showing only changes of commit 8e3d7e604b - Show all commits
@@ -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
@@ -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() {
@@ -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()