feat(scene): add scene editor, executor and fakes (phase 1) #5

Merged
KremeCN merged 10 commits from feat/scene-editor into main 2026-08-25 20:20:19 +08:00
7 changed files with 329 additions and 100 deletions
Showing only changes of commit b48079564f - Show all commits
@@ -223,6 +223,14 @@ fun SceneEditorScreen(
} }
} }
if (state.triggers.isEmpty()) {
Text(
text = stringResource(R.string.scene_empty_triggers_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
state.triggers.forEach { trigger -> state.triggers.forEach { trigger ->
TriggerItemRow(trigger = trigger, onRemove = { viewModel.removeTrigger(trigger.id) }) TriggerItemRow(trigger = trigger, onRemove = { viewModel.removeTrigger(trigger.id) })
} }
@@ -273,6 +281,14 @@ fun SceneEditorScreen(
} }
} }
if (state.conditions.isEmpty()) {
Text(
text = stringResource(R.string.scene_empty_conditions_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
state.conditions.forEach { condition -> state.conditions.forEach { condition ->
ConditionItemRow(condition = condition, onRemove = { viewModel.removeCondition(condition.id) }) ConditionItemRow(condition = condition, onRemove = { viewModel.removeCondition(condition.id) })
} }
@@ -327,7 +343,7 @@ fun SceneEditorScreen(
if (state.actions.isEmpty()) { if (state.actions.isEmpty()) {
Text( Text(
text = "暂未添加动作,请点击下方按钮添加", text = stringResource(R.string.scene_empty_actions_hint),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -514,14 +530,17 @@ fun SceneEditorScreen(
} }
} }
val defaultSensorName = stringResource(R.string.device_temperature_humidity_title)
val defaultSocketName = stringResource(R.string.scene_default_socket_name)
if (showTriggerDialog) { if (showTriggerDialog) {
AddTriggerDialog( AddTriggerDialog(
onDismiss = { showTriggerDialog = false }, onDismiss = { showTriggerDialog = false },
onAdd = { triggerType, time, temp, hum, op -> onAdd = { triggerType, time, temp, hum, op ->
when (triggerType) { when (triggerType) {
0 -> viewModel.addTimeTrigger(time) 0 -> viewModel.addTimeTrigger(time)
1 -> viewModel.addTemperatureTrigger("温湿度传感器", op, temp) 1 -> viewModel.addTemperatureTrigger(defaultSensorName, op, temp)
2 -> viewModel.addHumidityTrigger("温湿度传感器", op, hum) 2 -> viewModel.addHumidityTrigger(defaultSensorName, op, hum)
} }
showTriggerDialog = false showTriggerDialog = false
}, },
@@ -532,15 +551,15 @@ fun SceneEditorScreen(
AddConditionDialog( AddConditionDialog(
onDismiss = { showConditionDialog = false }, onDismiss = { showConditionDialog = false },
onAddTemp = { op, temp -> onAddTemp = { op, temp ->
viewModel.addTemperatureCondition("温湿度传感器", op, temp) viewModel.addTemperatureCondition(defaultSensorName, op, temp)
showConditionDialog = false showConditionDialog = false
}, },
onAddHumidity = { op, hum -> onAddHumidity = { op, hum ->
viewModel.addHumidityCondition("温湿度传感器", op, hum) viewModel.addHumidityCondition(defaultSensorName, op, hum)
showConditionDialog = false showConditionDialog = false
}, },
onAddSocket = { powerState -> onAddSocket = { powerState ->
viewModel.addSocketPowerCondition("Zigbee插座控制器", powerState) viewModel.addSocketPowerCondition(defaultSocketName, powerState)
showConditionDialog = false showConditionDialog = false
}, },
onAddTimeRange = { start, end -> onAddTimeRange = { start, end ->
@@ -588,10 +607,10 @@ private fun TriggerItemRow(trigger: SceneTrigger, onRemove: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
val (icon, desc) = when (trigger) { val (icon, desc) = when (trigger) {
is SceneTrigger.Time -> Pair("⏰", "定时: ${trigger.timeOfDay}") is SceneTrigger.Time -> Pair("⏰", stringResource(R.string.scene_trigger_type_time) + ": ${trigger.timeOfDay}")
is SceneTrigger.Temperature -> Pair("🌡️", "温度 ${trigger.operator.symbol} ${trigger.thresholdCelsius}°C") is SceneTrigger.Temperature -> Pair("🌡️", stringResource(R.string.scene_trigger_desc_temp, trigger.operator.symbol, trigger.thresholdCelsius.toInt()))
is SceneTrigger.Humidity -> Pair("💧", "湿度 ${trigger.operator.symbol} ${trigger.thresholdPercent}%") is SceneTrigger.Humidity -> Pair("💧", stringResource(R.string.scene_trigger_desc_humidity, trigger.operator.symbol, trigger.thresholdPercent.toInt()))
is SceneTrigger.DeviceState -> Pair("🔌", "${trigger.deviceName.value} 状态变为 ${trigger.expectedState}") is SceneTrigger.DeviceState -> Pair("🔌", stringResource(R.string.scene_trigger_desc_device, trigger.deviceName.value, trigger.expectedState))
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(icon, style = MaterialTheme.typography.titleMedium) Text(icon, style = MaterialTheme.typography.titleMedium)
@@ -618,10 +637,10 @@ private fun ConditionItemRow(condition: SceneCondition, onRemove: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
val (icon, desc) = when (condition) { val (icon, desc) = when (condition) {
is SceneCondition.Temperature -> Pair("🌡️", "温度需 ${condition.operator.symbol} ${condition.thresholdCelsius}°C") is SceneCondition.Temperature -> Pair("🌡️", stringResource(R.string.scene_condition_desc_temp, condition.operator.symbol, condition.thresholdCelsius.toInt()))
is SceneCondition.Humidity -> Pair("💧", "湿度需 ${condition.operator.symbol} ${condition.thresholdPercent}%") is SceneCondition.Humidity -> Pair("💧", stringResource(R.string.scene_condition_desc_humidity, condition.operator.symbol, condition.thresholdPercent.toInt()))
is SceneCondition.SocketPower -> Pair("🔌", "${condition.deviceName.value} 处于 ${condition.expectedState.name}") is SceneCondition.SocketPower -> Pair("🔌", stringResource(R.string.scene_condition_desc_socket, condition.deviceName.value, condition.expectedState.name))
is SceneCondition.TimeRange -> Pair("🕒", "时间在 ${condition.startTime} ~ ${condition.endTime} 之间") is SceneCondition.TimeRange -> Pair("🕒", stringResource(R.string.scene_condition_desc_time, condition.startTime, condition.endTime))
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(icon, style = MaterialTheme.typography.titleMedium) Text(icon, style = MaterialTheme.typography.titleMedium)
@@ -748,15 +767,18 @@ private fun AddTriggerDialog(
var humThreshold by remember { mutableDoubleStateOf(40.0) } var humThreshold by remember { mutableDoubleStateOf(40.0) }
var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) } var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) }
val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") }
val isTimeValid = timeRegex.matches(timeInput.trim())
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.scene_dialog_add_trigger_title)) }, title = { Text(stringResource(R.string.scene_dialog_add_trigger_title)) },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text("⏰ 定时") }) FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_time)) })
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text("🌡️ 温度") }) FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text("💧 湿度") }) FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
} }
when (selectedType) { when (selectedType) {
0 -> { 0 -> {
@@ -765,19 +787,23 @@ private fun AddTriggerDialog(
onValueChange = { timeInput = it }, onValueChange = { timeInput = it },
label = { Text(stringResource(R.string.scene_trigger_time_label)) }, label = { Text(stringResource(R.string.scene_trigger_time_label)) },
singleLine = true, singleLine = true,
isError = !isTimeValid,
supportingText = if (!isTimeValid) {
{ Text(stringResource(R.string.scene_trigger_time_error)) }
} else null,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
1, 2 -> { 1, 2 -> {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text("> 高于") }) 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("< 低于") }) FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
} }
if (selectedType == 1) { if (selectedType == 1) {
Text("设定阈值: ${tempThreshold.toInt()} °C") Text(stringResource(R.string.scene_threshold_temp, tempThreshold.toInt()))
Slider(value = tempThreshold.toFloat(), onValueChange = { tempThreshold = it.toDouble() }, valueRange = 10f..40f) Slider(value = tempThreshold.toFloat(), onValueChange = { tempThreshold = it.toDouble() }, valueRange = 10f..40f)
} else { } else {
Text("设定阈值: ${humThreshold.toInt()} %") Text(stringResource(R.string.scene_threshold_hum, humThreshold.toInt()))
Slider(value = humThreshold.toFloat(), onValueChange = { humThreshold = it.toDouble() }, valueRange = 10f..90f) Slider(value = humThreshold.toFloat(), onValueChange = { humThreshold = it.toDouble() }, valueRange = 10f..90f)
} }
} }
@@ -785,7 +811,11 @@ private fun AddTriggerDialog(
} }
}, },
confirmButton = { confirmButton = {
Button(onClick = { onAdd(selectedType, timeInput, tempThreshold, humThreshold, operator) }) { val canConfirm = selectedType != 0 || isTimeValid
Button(
onClick = { onAdd(selectedType, timeInput.trim(), tempThreshold, humThreshold, operator) },
enabled = canConfirm,
) {
Text(stringResource(R.string.scene_add_action)) Text(stringResource(R.string.scene_add_action))
} }
}, },
@@ -811,57 +841,86 @@ private fun AddConditionDialog(
var startTime by remember { mutableStateOf("22:00") } var startTime by remember { mutableStateOf("22:00") }
var endTime by remember { mutableStateOf("07:00") } var endTime by remember { mutableStateOf("07:00") }
val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") }
val isStartTimeValid = timeRegex.matches(startTime.trim())
val isEndTimeValid = timeRegex.matches(endTime.trim())
val isTimeRangeValid = isStartTimeValid && isEndTimeValid
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) }, title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text("🌡️ 温度") }) FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text("💧 湿度") }) FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text("🔌 插座") }) FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
FilterChip(selected = selectedType == 3, onClick = { selectedType = 3 }, label = { Text("🕒 时间") }) FilterChip(selected = selectedType == 3, onClick = { selectedType = 3 }, label = { Text(stringResource(R.string.scene_chip_time)) })
} }
when (selectedType) { when (selectedType) {
0 -> { 0 -> {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text("> 高于") }) 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("< 低于") }) FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
} }
Text("设定温度: ${tempVal.toInt()} °C") Text(stringResource(R.string.scene_threshold_temp, tempVal.toInt()))
Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f) Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f)
} }
1 -> { 1 -> {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text("> 高于") }) 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("< 低于") }) FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
} }
Text("设定湿度: ${humVal.toInt()} %") Text(stringResource(R.string.scene_threshold_hum, humVal.toInt()))
Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f) Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f)
} }
2 -> { 2 -> {
Text("要求插座当前状态为:") Text(stringResource(R.string.scene_target_socket_state))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text("开启") }) FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text("关闭") }) FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
} }
} }
3 -> { 3 -> {
OutlinedTextField(value = startTime, onValueChange = { startTime = it }, label = { Text(stringResource(R.string.scene_condition_start_time)) }, singleLine = true) OutlinedTextField(
OutlinedTextField(value = endTime, onValueChange = { endTime = it }, label = { Text(stringResource(R.string.scene_condition_end_time)) }, singleLine = true) value = startTime,
onValueChange = { startTime = it },
label = { Text(stringResource(R.string.scene_condition_start_time)) },
singleLine = true,
isError = !isStartTimeValid,
supportingText = if (!isStartTimeValid) {
{ Text(stringResource(R.string.scene_condition_time_error)) }
} else null,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = endTime,
onValueChange = { endTime = it },
label = { Text(stringResource(R.string.scene_condition_end_time)) },
singleLine = true,
isError = !isEndTimeValid,
supportingText = if (!isEndTimeValid) {
{ Text(stringResource(R.string.scene_condition_time_error)) }
} else null,
modifier = Modifier.fillMaxWidth(),
)
} }
} }
} }
}, },
confirmButton = { confirmButton = {
Button(onClick = { val canConfirm = selectedType != 3 || isTimeRangeValid
Button(
onClick = {
when (selectedType) { when (selectedType) {
0 -> onAddTemp(operator, tempVal) 0 -> onAddTemp(operator, tempVal)
1 -> onAddHumidity(operator, humVal) 1 -> onAddHumidity(operator, humVal)
2 -> onAddSocket(socketPower) 2 -> onAddSocket(socketPower)
3 -> onAddTimeRange(startTime, endTime) 3 -> onAddTimeRange(startTime.trim(), endTime.trim())
} }
}) { },
enabled = canConfirm,
) {
Text(stringResource(R.string.scene_add_action)) Text(stringResource(R.string.scene_add_action))
} }
}, },
@@ -882,17 +941,23 @@ private fun AddActionDialog(
onAddNotify: (title: String, msg: String) -> Unit, onAddNotify: (title: String, msg: String) -> Unit,
) { ) {
var actionType by remember { mutableIntStateOf(0) } var actionType by remember { mutableIntStateOf(0) }
var socketName by remember { mutableStateOf("Zigbee插座控制器") } val defaultSocketName = stringResource(R.string.scene_default_socket_name)
val defaultIrName = stringResource(R.string.scene_default_ir_name)
val defaultPowerBtn = stringResource(R.string.scene_default_button_power)
val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title)
val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg)
var socketName by remember { mutableStateOf(defaultSocketName) }
var socketPower by remember { mutableStateOf(SocketPowerState.ON) } var socketPower by remember { mutableStateOf(SocketPowerState.ON) }
var selectedRemoteIndex by remember { mutableIntStateOf(0) } var selectedRemoteIndex by remember { mutableIntStateOf(0) }
var keyBindings by remember { mutableStateOf<List<RemoteKeyBinding>>(emptyList()) } var keyBindings by remember { mutableStateOf<List<RemoteKeyBinding>>(emptyList()) }
var selectedButtonId by remember { mutableStateOf("power") } var selectedButtonId by remember { mutableStateOf("power") }
var selectedButtonName by remember { mutableStateOf("电源") } var selectedButtonName by remember { mutableStateOf(defaultPowerBtn) }
var delaySeconds by remember { mutableIntStateOf(3) } var delaySeconds by remember { mutableIntStateOf(3) }
var notifyTitle by remember { mutableStateOf("情景完成") } var notifyTitle by remember { mutableStateOf(defaultNotifyTitle) }
var notifyMsg by remember { mutableStateOf("自动化任务执行完成") } var notifyMsg by remember { mutableStateOf(defaultNotifyMsg) }
LaunchedEffect(availableRemotes, selectedRemoteIndex) { LaunchedEffect(availableRemotes, selectedRemoteIndex) {
if (availableRemotes.isNotEmpty() && selectedRemoteIndex in availableRemotes.indices) { if (availableRemotes.isNotEmpty() && selectedRemoteIndex in availableRemotes.indices) {
@@ -905,7 +970,7 @@ private fun AddActionDialog(
selectedButtonName = bindings[0].displayName.ifBlank { bindings[0].buttonId } selectedButtonName = bindings[0].displayName.ifBlank { bindings[0].buttonId }
} else { } else {
selectedButtonId = "power" selectedButtonId = "power"
selectedButtonName = "电源" selectedButtonName = defaultPowerBtn
} }
} else { } else {
keyBindings = emptyList() keyBindings = emptyList()
@@ -918,10 +983,10 @@ private fun AddActionDialog(
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip(selected = actionType == 0, onClick = { actionType = 0 }, label = { Text("🔌 插座") }) FilterChip(selected = actionType == 0, onClick = { actionType = 0 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
FilterChip(selected = actionType == 1, onClick = { actionType = 1 }, label = { Text("📺 遥控") }) FilterChip(selected = actionType == 1, onClick = { actionType = 1 }, label = { Text(stringResource(R.string.scene_chip_remote)) })
FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text("⏱️ 延时") }) FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text(stringResource(R.string.scene_chip_delay)) })
FilterChip(selected = actionType == 3, onClick = { actionType = 3 }, label = { Text("🔔 提示") }) FilterChip(selected = actionType == 3, onClick = { actionType = 3 }, label = { Text(stringResource(R.string.scene_chip_notify)) })
} }
when (actionType) { when (actionType) {
0 -> { 0 -> {
@@ -934,14 +999,14 @@ private fun AddActionDialog(
) )
Text(stringResource(R.string.scene_socket_power_state)) Text(stringResource(R.string.scene_socket_power_state))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text("开启") }) FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text("关闭") }) FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
} }
} }
1 -> { 1 -> {
if (availableRemotes.isEmpty()) { if (availableRemotes.isEmpty()) {
Text("未找到虚拟遥控器,将默认使用红外控制器") Text(stringResource(R.string.scene_no_virtual_remotes))
OutlinedTextField(value = selectedButtonId, onValueChange = { selectedButtonId = it }, label = { Text("按键名称/槽位") }, singleLine = true) OutlinedTextField(value = selectedButtonId, onValueChange = { selectedButtonId = it }, label = { Text(stringResource(R.string.scene_remote_button_slot_label)) }, singleLine = true)
} else { } else {
Text(stringResource(R.string.scene_select_virtual_remote)) Text(stringResource(R.string.scene_select_virtual_remote))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -969,12 +1034,12 @@ private fun AddActionDialog(
} }
} }
} else { } else {
OutlinedTextField(value = selectedButtonName, onValueChange = { selectedButtonName = it; selectedButtonId = it }, label = { Text("按键名称") }, singleLine = true) OutlinedTextField(value = selectedButtonName, onValueChange = { selectedButtonName = it; selectedButtonId = it }, label = { Text(stringResource(R.string.scene_remote_button_name_label)) }, singleLine = true)
} }
} }
} }
2 -> { 2 -> {
Text(stringResource(R.string.scene_delay_duration_label) + ": $delaySeconds 秒") Text(stringResource(R.string.scene_delay_duration_label) + ": " + stringResource(R.string.scene_unit_seconds, delaySeconds))
Slider( Slider(
value = delaySeconds.toFloat(), value = delaySeconds.toFloat(),
onValueChange = { delaySeconds = it.toInt() }, onValueChange = { delaySeconds = it.toInt() },
@@ -998,7 +1063,7 @@ private fun AddActionDialog(
if (remote != null) { if (remote != null) {
onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName) onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName)
} else { } else {
onAddRemote("default-ir", "红外控制器", selectedButtonId, selectedButtonName) onAddRemote("default-ir", defaultIrName, selectedButtonId, selectedButtonName)
} }
} }
2 -> onAddDelay(delaySeconds) 2 -> onAddDelay(delaySeconds)
@@ -116,7 +116,7 @@ private fun CenteredMessage(paddingValues: PaddingValues, message: String, onRet
Button(onClick = onRetry) { Button(onClick = onRetry) {
Icon(Icons.Filled.Refresh, contentDescription = null) Icon(Icons.Filled.Refresh, contentDescription = null)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("重试") Text(stringResource(R.string.action_retry))
} }
} }
} }
@@ -137,7 +137,7 @@ private fun ReadyContent(
) { ) {
Text(sceneName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text(sceneName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("点击下方按钮立即开始执行", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(stringResource(R.string.scene_exec_ready_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Button( Button(
onClick = onExecute, onClick = onExecute,
@@ -247,14 +247,14 @@ private fun CompletedContent(
) { ) {
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("重新执行") Text(stringResource(R.string.scene_exec_rerun))
} }
Button( Button(
onClick = onBack, onClick = onBack,
modifier = Modifier.weight(1f).height(48.dp), modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
) { ) {
Text("返回", fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.action_back), fontWeight = FontWeight.SemiBold)
} }
} }
} }
@@ -310,7 +310,7 @@ private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult)
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = "步骤 $stepNumber: ${entry.action.describe()}", text = stringResource(R.string.scene_step_format, stepNumber, entry.action.describe()),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
@@ -334,11 +334,12 @@ private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult)
} }
} }
@Composable
private fun SceneAction.describe(): String = when (this) { private fun SceneAction.describe(): String = when (this) {
is SceneAction.SocketPower -> "插座【${deviceName.value}】-> ${powerState.name}" is SceneAction.SocketPower -> stringResource(R.string.scene_action_socket, deviceName.value, powerState.name)
is SceneAction.VirtualRemoteButton -> "遥控【$remoteDisplayName】-> $buttonDisplayName" is SceneAction.VirtualRemoteButton -> stringResource(R.string.scene_action_remote, remoteDisplayName, buttonDisplayName)
is SceneAction.InfraredKey -> "红外【${deviceName.value}】-> 槽位 $key" is SceneAction.InfraredKey -> stringResource(R.string.scene_action_infrared, deviceName.value, key)
is SceneAction.Delay -> "⏱️ 等待 ${durationSeconds} 秒" is SceneAction.Delay -> stringResource(R.string.scene_action_delay, durationSeconds)
is SceneAction.Notification -> "🔔 提示: $title" is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title)
} }
@@ -305,13 +305,17 @@ private fun SceneCard(
) { ) {
if (scene.type == AutomationType.AUTOMATIC) { if (scene.type == AutomationType.AUTOMATIC) {
Text( Text(
text = if (scene.triggers.isNotEmpty()) "⚡ ${scene.triggers.size} 个触发器 • ${scene.actions.size} 个动作" else "${scene.actions.size} 个动作", text = if (scene.triggers.isNotEmpty()) {
stringResource(R.string.scene_summary_triggers_and_actions, scene.triggers.size, scene.actions.size)
} else {
stringResource(R.string.scene_summary_actions_only, scene.actions.size)
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
Text( Text(
text = "${scene.actions.size} 个执行动作", text = stringResource(R.string.scene_summary_actions_only, scene.actions.size),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -161,6 +161,47 @@
<string name="scene_step_failed">执行失败</string> <string name="scene_step_failed">执行失败</string>
<string name="scene_step_skipped">已跳过</string> <string name="scene_step_skipped">已跳过</string>
<!-- 校验与弹窗选项/占位文案 -->
<string name="scene_trigger_time_error">时间格式无效(请按 HH:mm 格式输入,如 07:30)</string>
<string name="scene_condition_time_error">时间格式无效(请按 HH:mm 格式输入,如 22:00)</string>
<string name="scene_chip_time">⏰ 定时</string>
<string name="scene_chip_temp">🌡️ 温度</string>
<string name="scene_chip_humidity">💧 湿度</string>
<string name="scene_chip_socket">🔌 插座</string>
<string name="scene_chip_remote">📺 遥控</string>
<string name="scene_chip_delay">⏱️ 延时</string>
<string name="scene_chip_notify">🔔 提示</string>
<string name="scene_chip_above">&gt; 高于</string>
<string name="scene_chip_below">&lt; 低于</string>
<string name="scene_chip_power_on">开启</string>
<string name="scene_chip_power_off">关闭</string>
<string name="scene_threshold_temp">设定阈值:%1$d °C</string>
<string name="scene_threshold_hum">设定阈值:%1$d %%</string>
<string name="scene_target_socket_state">要求插座当前状态为:</string>
<string name="scene_no_virtual_remotes">未找到虚拟遥控器,将默认使用红外控制器</string>
<string name="scene_remote_button_slot_label">按键名称/槽位</string>
<string name="scene_remote_button_name_label">按键名称</string>
<string name="scene_empty_triggers_hint">尚未添加触发器,请点击下方按钮添加</string>
<string name="scene_empty_conditions_hint">尚未添加限制条件,请点击下方按钮添加</string>
<string name="scene_empty_actions_hint">尚未添加执行动作,请点击下方按钮添加</string>
<string name="scene_summary_triggers_and_actions">⚡ %1$d 个触发器 • %2$d 个动作</string>
<string name="scene_summary_actions_only">%1$d 个执行动作</string>
<string name="scene_exec_ready_hint">点击下方按钮立即开始执行</string>
<string name="scene_exec_rerun">重新执行</string>
<string name="scene_step_format">步骤 %1$d:%2$s</string>
<string name="scene_trigger_desc_temp">温度 %1$s %2$s°C</string>
<string name="scene_trigger_desc_humidity">湿度 %1$s %2$s%%</string>
<string name="scene_trigger_desc_device">%1$s 状态变为 %2$s</string>
<string name="scene_condition_desc_temp">温度需 %1$s %2$s°C</string>
<string name="scene_condition_desc_humidity">湿度需 %1$s %2$s%%</string>
<string name="scene_condition_desc_socket">%1$s 处于 %2$s</string>
<string name="scene_condition_desc_time">时间在 %1$s ~ %2$s 之间</string>
<string name="scene_default_socket_name">Zigbee插座控制器</string>
<string name="scene_default_ir_name">红外控制器</string>
<string name="scene_default_button_power">电源</string>
<string name="scene_default_notify_title">情景完成</string>
<string name="scene_default_notify_msg">自动化任务执行完成</string>
<!-- 设备列表 --> <!-- 设备列表 -->
<string name="device_list_title">设备列表</string> <string name="device_list_title">设备列表</string>
<string name="device_list_description">选择需要控制或查看状态的 ABox 终端设备。</string> <string name="device_list_description">选择需要控制或查看状态的 ABox 终端设备。</string>
@@ -161,6 +161,47 @@
<string name="scene_step_failed">Failed</string> <string name="scene_step_failed">Failed</string>
<string name="scene_step_skipped">Skipped</string> <string name="scene_step_skipped">Skipped</string>
<!-- Validation & dialog chips / placeholders -->
<string name="scene_trigger_time_error">Invalid time format (use HH:mm, e.g. 07:30)</string>
<string name="scene_condition_time_error">Invalid time format (use HH:mm, e.g. 22:00)</string>
<string name="scene_chip_time">⏰ Schedule</string>
<string name="scene_chip_temp">🌡️ Temperature</string>
<string name="scene_chip_humidity">💧 Humidity</string>
<string name="scene_chip_socket">🔌 Socket</string>
<string name="scene_chip_remote">📺 Remote</string>
<string name="scene_chip_delay">⏱️ Delay</string>
<string name="scene_chip_notify">🔔 Notify</string>
<string name="scene_chip_above">&gt; Above</string>
<string name="scene_chip_below">&lt; Below</string>
<string name="scene_chip_power_on">Turn ON</string>
<string name="scene_chip_power_off">Turn OFF</string>
<string name="scene_threshold_temp">Target threshold: %1$d °C</string>
<string name="scene_threshold_hum">Target threshold: %1$d %%</string>
<string name="scene_target_socket_state">Require socket state to be:</string>
<string name="scene_no_virtual_remotes">No virtual remotes found; using infrared controller by default.</string>
<string name="scene_remote_button_slot_label">Button Name / Slot</string>
<string name="scene_remote_button_name_label">Button Name</string>
<string name="scene_empty_triggers_hint">No triggers configured. Tap + below to add.</string>
<string name="scene_empty_conditions_hint">No conditions configured. Tap + below to add.</string>
<string name="scene_empty_actions_hint">No actions configured. Tap + below to add.</string>
<string name="scene_summary_triggers_and_actions">⚡ %1$d triggers • %2$d actions</string>
<string name="scene_summary_actions_only">%1$d actions</string>
<string name="scene_exec_ready_hint">Tap the button below to run immediately</string>
<string name="scene_exec_rerun">Re-run</string>
<string name="scene_step_format">Step %1$d: %2$s</string>
<string name="scene_trigger_desc_temp">Temp %1$s %2$s°C</string>
<string name="scene_trigger_desc_humidity">Humidity %1$s %2$s%%</string>
<string name="scene_trigger_desc_device">%1$s state changed to %2$s</string>
<string name="scene_condition_desc_temp">Temp must be %1$s %2$s°C</string>
<string name="scene_condition_desc_humidity">Humidity must be %1$s %2$s%%</string>
<string name="scene_condition_desc_socket">%1$s is %2$s</string>
<string name="scene_condition_desc_time">Time is between %1$s ~ %2$s</string>
<string name="scene_default_socket_name">Zigbee Smart Socket</string>
<string name="scene_default_ir_name">Infrared Controller</string>
<string name="scene_default_button_power">Power</string>
<string name="scene_default_notify_title">Scene Completed</string>
<string name="scene_default_notify_msg">Automation task executed successfully</string>
<!-- Device list --> <!-- Device list -->
<string name="device_list_title">Device List</string> <string name="device_list_title">Device List</string>
<string name="device_list_description">Select an ABox terminal device to control or check status.</string> <string name="device_list_description">Select an ABox terminal device to control or check status.</string>
@@ -25,6 +25,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -201,13 +202,17 @@ class FakeSceneRepository : SceneRepository {
} }
/** /**
* Fake [SceneExecutor] that drives the existing [SocketGateway] and [InfraredGateway] fakes. * Fake [SceneExecutor] that drives the existing [SocketGateway] and [InfraredGateway] fakes,
* respecting delays and recording virtual remote / notification actions.
*/ */
class FakeSceneExecutor( class FakeSceneExecutor(
private val socketGateway: SocketGateway, private val socketGateway: SocketGateway,
private val infraredGateway: InfraredGateway, private val infraredGateway: InfraredGateway,
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
) : SceneExecutor { ) : SceneExecutor {
private val running = ConcurrentHashMap<SceneId, Boolean>() private val running = ConcurrentHashMap<SceneId, Boolean>()
val executedRemoteButtons = mutableListOf<SceneAction.VirtualRemoteButton>()
val sentNotifications = mutableListOf<SceneAction.Notification>()
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> { override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
val alreadyRunning = running.putIfAbsent(scene.id, true) != null val alreadyRunning = running.putIfAbsent(scene.id, true) != null
@@ -277,24 +282,65 @@ class FakeSceneExecutor(
action: SceneAction, action: SceneAction,
): SceneActionExecutionResult { ): SceneActionExecutionResult {
return try { return try {
val result = when (action) { when (action) {
is SceneAction.SocketPower -> socketGateway.setPower( is SceneAction.SocketPower -> {
val result = socketGateway.setPower(
profileId = profileId, profileId = profileId,
deviceName = action.deviceName, deviceName = action.deviceName,
powerState = action.powerState, powerState = action.powerState,
) )
toExecutionResult(action, result)
}
is SceneAction.InfraredKey -> infraredGateway.sendKey( is SceneAction.InfraredKey -> {
val result = infraredGateway.sendKey(
profileId = profileId, profileId = profileId,
deviceName = action.deviceName, deviceName = action.deviceName,
key = action.key, key = action.key,
) )
toExecutionResult(action, result)
is SceneAction.VirtualRemoteButton,
is SceneAction.Delay,
is SceneAction.Notification -> DomainResult.Success(Unit)
} }
when (result) {
is SceneAction.VirtualRemoteButton -> {
executedRemoteButtons.add(action)
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
is SceneAction.Delay -> {
delayDurationProvider(action.durationSeconds * 1000L)
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
is SceneAction.Notification -> {
sentNotifications.add(action)
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(detail = e.message),
)
}
}
private fun toExecutionResult(action: SceneAction, result: DomainResult<*>): SceneActionExecutionResult {
return when (result) {
is DomainResult.Success -> SceneActionExecutionResult( is DomainResult.Success -> SceneActionExecutionResult(
action = action, action = action,
outcome = SceneActionOutcome.SUCCESS, outcome = SceneActionOutcome.SUCCESS,
@@ -307,14 +353,5 @@ class FakeSceneExecutor(
error = result.error, error = result.error,
) )
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(detail = e.message),
)
}
} }
} }
@@ -116,6 +116,46 @@ class FakeSceneExecutorTest {
assertFalse(executionResult.allSucceeded) assertFalse(executionResult.allSucceeded)
} }
@Test
fun executesDelayVirtualRemoteAndNotification() = runTest {
val delays = mutableListOf<Long>()
val executor = FakeSceneExecutor(
socketGateway = FakeSocketGateway(),
infraredGateway = FakeInfraredGateway(),
delayDurationProvider = { delays.add(it) },
)
val remoteAction = SceneAction.VirtualRemoteButton(
id = SceneActionId("abox:btn-1"),
profileId = profileId,
remoteId = "remote-1",
remoteDisplayName = "TV Remote",
buttonId = "power",
buttonDisplayName = "Power",
)
val delayAction = SceneAction.Delay(
id = SceneActionId("abox:delay-1"),
profileId = profileId,
durationSeconds = 5,
)
val notificationAction = SceneAction.Notification(
id = SceneActionId("abox:notif-1"),
profileId = profileId,
title = "Test Done",
message = "Success",
)
val scene = scene(listOf(remoteAction, delayAction, notificationAction))
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val execResult = (result as DomainResult.Success).value
assertTrue(execResult.allSucceeded)
assertEquals(listOf(5000L), delays)
assertEquals(listOf(remoteAction), executor.executedRemoteButtons)
assertEquals(listOf(notificationAction), executor.sentNotifications)
}
private fun scene(actions: List<SceneAction>): Scene = Scene( private fun scene(actions: List<SceneAction>): Scene = Scene(
id = SceneId("${profileId.value}:scene-test"), id = SceneId("${profileId.value}:scene-test"),
profileId = profileId, profileId = profileId,