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 c99a702..555ad99 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 @@ -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 -> 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 -> ConditionItemRow(condition = condition, onRemove = { viewModel.removeCondition(condition.id) }) } @@ -327,7 +343,7 @@ fun SceneEditorScreen( if (state.actions.isEmpty()) { Text( - text = "暂未添加动作,请点击下方按钮添加", + text = stringResource(R.string.scene_empty_actions_hint), style = MaterialTheme.typography.bodySmall, 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) { AddTriggerDialog( onDismiss = { showTriggerDialog = false }, onAdd = { triggerType, time, temp, hum, op -> when (triggerType) { 0 -> viewModel.addTimeTrigger(time) - 1 -> viewModel.addTemperatureTrigger("温湿度传感器", op, temp) - 2 -> viewModel.addHumidityTrigger("温湿度传感器", op, hum) + 1 -> viewModel.addTemperatureTrigger(defaultSensorName, op, temp) + 2 -> viewModel.addHumidityTrigger(defaultSensorName, op, hum) } showTriggerDialog = false }, @@ -532,15 +551,15 @@ fun SceneEditorScreen( AddConditionDialog( onDismiss = { showConditionDialog = false }, onAddTemp = { op, temp -> - viewModel.addTemperatureCondition("温湿度传感器", op, temp) + viewModel.addTemperatureCondition(defaultSensorName, op, temp) showConditionDialog = false }, onAddHumidity = { op, hum -> - viewModel.addHumidityCondition("温湿度传感器", op, hum) + viewModel.addHumidityCondition(defaultSensorName, op, hum) showConditionDialog = false }, onAddSocket = { powerState -> - viewModel.addSocketPowerCondition("Zigbee插座控制器", powerState) + viewModel.addSocketPowerCondition(defaultSocketName, powerState) showConditionDialog = false }, onAddTimeRange = { start, end -> @@ -588,10 +607,10 @@ private fun TriggerItemRow(trigger: SceneTrigger, onRemove: () -> Unit) { horizontalArrangement = Arrangement.SpaceBetween, ) { val (icon, desc) = when (trigger) { - is SceneTrigger.Time -> Pair("⏰", "定时: ${trigger.timeOfDay}") - is SceneTrigger.Temperature -> Pair("🌡️", "温度 ${trigger.operator.symbol} ${trigger.thresholdCelsius}°C") - is SceneTrigger.Humidity -> Pair("💧", "湿度 ${trigger.operator.symbol} ${trigger.thresholdPercent}%") - is SceneTrigger.DeviceState -> Pair("🔌", "${trigger.deviceName.value} 状态变为 ${trigger.expectedState}") + is SceneTrigger.Time -> Pair("⏰", stringResource(R.string.scene_trigger_type_time) + ": ${trigger.timeOfDay}") + is SceneTrigger.Temperature -> Pair("🌡️", stringResource(R.string.scene_trigger_desc_temp, trigger.operator.symbol, trigger.thresholdCelsius.toInt())) + is SceneTrigger.Humidity -> Pair("💧", stringResource(R.string.scene_trigger_desc_humidity, trigger.operator.symbol, trigger.thresholdPercent.toInt())) + is SceneTrigger.DeviceState -> Pair("🔌", stringResource(R.string.scene_trigger_desc_device, trigger.deviceName.value, trigger.expectedState)) } Row(verticalAlignment = Alignment.CenterVertically) { Text(icon, style = MaterialTheme.typography.titleMedium) @@ -618,10 +637,10 @@ private fun ConditionItemRow(condition: SceneCondition, onRemove: () -> Unit) { horizontalArrangement = Arrangement.SpaceBetween, ) { val (icon, desc) = when (condition) { - is SceneCondition.Temperature -> Pair("🌡️", "温度需 ${condition.operator.symbol} ${condition.thresholdCelsius}°C") - is SceneCondition.Humidity -> Pair("💧", "湿度需 ${condition.operator.symbol} ${condition.thresholdPercent}%") - is SceneCondition.SocketPower -> Pair("🔌", "${condition.deviceName.value} 处于 ${condition.expectedState.name}") - is SceneCondition.TimeRange -> Pair("🕒", "时间在 ${condition.startTime} ~ ${condition.endTime} 之间") + is SceneCondition.Temperature -> Pair("🌡️", stringResource(R.string.scene_condition_desc_temp, condition.operator.symbol, condition.thresholdCelsius.toInt())) + is SceneCondition.Humidity -> Pair("💧", stringResource(R.string.scene_condition_desc_humidity, condition.operator.symbol, condition.thresholdPercent.toInt())) + is SceneCondition.SocketPower -> Pair("🔌", stringResource(R.string.scene_condition_desc_socket, condition.deviceName.value, condition.expectedState.name)) + is SceneCondition.TimeRange -> Pair("🕒", stringResource(R.string.scene_condition_desc_time, condition.startTime, condition.endTime)) } Row(verticalAlignment = Alignment.CenterVertically) { Text(icon, style = MaterialTheme.typography.titleMedium) @@ -748,15 +767,18 @@ private fun AddTriggerDialog( var humThreshold by remember { mutableDoubleStateOf(40.0) } 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( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.scene_dialog_add_trigger_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text("⏰ 定时") }) - FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text("🌡️ 温度") }) - FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, 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(stringResource(R.string.scene_chip_temp)) }) + FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_humidity)) }) } when (selectedType) { 0 -> { @@ -765,19 +787,23 @@ private fun AddTriggerDialog( onValueChange = { timeInput = it }, label = { Text(stringResource(R.string.scene_trigger_time_label)) }, singleLine = true, + isError = !isTimeValid, + supportingText = if (!isTimeValid) { + { Text(stringResource(R.string.scene_trigger_time_error)) } + } else null, modifier = Modifier.fillMaxWidth(), ) } 1, 2 -> { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text("> 高于") }) - FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_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(stringResource(R.string.scene_chip_below)) }) } 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) } else { - Text("设定阈值: ${humThreshold.toInt()} %") + Text(stringResource(R.string.scene_threshold_hum, humThreshold.toInt())) Slider(value = humThreshold.toFloat(), onValueChange = { humThreshold = it.toDouble() }, valueRange = 10f..90f) } } @@ -785,7 +811,11 @@ private fun AddTriggerDialog( } }, 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)) } }, @@ -811,57 +841,86 @@ private fun AddConditionDialog( var startTime by remember { mutableStateOf("22: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( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text("🌡️ 温度") }) - FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text("💧 湿度") }) - FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text("🔌 插座") }) - FilterChip(selected = selectedType == 3, onClick = { selectedType = 3 }, 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(stringResource(R.string.scene_chip_humidity)) }) + FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_socket)) }) + FilterChip(selected = selectedType == 3, onClick = { selectedType = 3 }, label = { Text(stringResource(R.string.scene_chip_time)) }) } when (selectedType) { 0 -> { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text("> 高于") }) - FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_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(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) } 1 -> { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text("> 高于") }) - FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_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(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) } 2 -> { - Text("要求插座当前状态为:") + 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("开启") }) - FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, 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(stringResource(R.string.scene_chip_power_off)) }) } } 3 -> { - OutlinedTextField(value = startTime, onValueChange = { startTime = it }, label = { Text(stringResource(R.string.scene_condition_start_time)) }, singleLine = true) - OutlinedTextField(value = endTime, onValueChange = { endTime = it }, label = { Text(stringResource(R.string.scene_condition_end_time)) }, singleLine = true) + OutlinedTextField( + 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 = { - Button(onClick = { - when (selectedType) { - 0 -> onAddTemp(operator, tempVal) - 1 -> onAddHumidity(operator, humVal) - 2 -> onAddSocket(socketPower) - 3 -> onAddTimeRange(startTime, endTime) - } - }) { + val canConfirm = selectedType != 3 || isTimeRangeValid + Button( + onClick = { + when (selectedType) { + 0 -> onAddTemp(operator, tempVal) + 1 -> onAddHumidity(operator, humVal) + 2 -> onAddSocket(socketPower) + 3 -> onAddTimeRange(startTime.trim(), endTime.trim()) + } + }, + enabled = canConfirm, + ) { Text(stringResource(R.string.scene_add_action)) } }, @@ -882,17 +941,23 @@ private fun AddActionDialog( onAddNotify: (title: String, msg: String) -> Unit, ) { 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 selectedRemoteIndex by remember { mutableIntStateOf(0) } var keyBindings by remember { mutableStateOf>(emptyList()) } var selectedButtonId by remember { mutableStateOf("power") } - var selectedButtonName by remember { mutableStateOf("电源") } + var selectedButtonName by remember { mutableStateOf(defaultPowerBtn) } var delaySeconds by remember { mutableIntStateOf(3) } - var notifyTitle by remember { mutableStateOf("情景完成") } - var notifyMsg by remember { mutableStateOf("自动化任务执行完成") } + var notifyTitle by remember { mutableStateOf(defaultNotifyTitle) } + var notifyMsg by remember { mutableStateOf(defaultNotifyMsg) } LaunchedEffect(availableRemotes, selectedRemoteIndex) { if (availableRemotes.isNotEmpty() && selectedRemoteIndex in availableRemotes.indices) { @@ -905,7 +970,7 @@ private fun AddActionDialog( selectedButtonName = bindings[0].displayName.ifBlank { bindings[0].buttonId } } else { selectedButtonId = "power" - selectedButtonName = "电源" + selectedButtonName = defaultPowerBtn } } else { keyBindings = emptyList() @@ -918,10 +983,10 @@ private fun AddActionDialog( text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - FilterChip(selected = actionType == 0, onClick = { actionType = 0 }, label = { Text("🔌 插座") }) - FilterChip(selected = actionType == 1, onClick = { actionType = 1 }, label = { Text("📺 遥控") }) - FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text("⏱️ 延时") }) - FilterChip(selected = actionType == 3, onClick = { actionType = 3 }, 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(stringResource(R.string.scene_chip_remote)) }) + FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text(stringResource(R.string.scene_chip_delay)) }) + FilterChip(selected = actionType == 3, onClick = { actionType = 3 }, label = { Text(stringResource(R.string.scene_chip_notify)) }) } when (actionType) { 0 -> { @@ -934,14 +999,14 @@ private fun AddActionDialog( ) 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("开启") }) - FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, 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(stringResource(R.string.scene_chip_power_off)) }) } } 1 -> { if (availableRemotes.isEmpty()) { - Text("未找到虚拟遥控器,将默认使用红外控制器") - OutlinedTextField(value = selectedButtonId, onValueChange = { selectedButtonId = it }, label = { Text("按键名称/槽位") }, singleLine = true) + Text(stringResource(R.string.scene_no_virtual_remotes)) + OutlinedTextField(value = selectedButtonId, onValueChange = { selectedButtonId = it }, label = { Text(stringResource(R.string.scene_remote_button_slot_label)) }, singleLine = true) } else { Text(stringResource(R.string.scene_select_virtual_remote)) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { @@ -969,12 +1034,12 @@ private fun AddActionDialog( } } } 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 -> { - 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( value = delaySeconds.toFloat(), onValueChange = { delaySeconds = it.toInt() }, @@ -998,7 +1063,7 @@ private fun AddActionDialog( if (remote != null) { onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName) } else { - onAddRemote("default-ir", "红外控制器", selectedButtonId, selectedButtonName) + onAddRemote("default-ir", defaultIrName, selectedButtonId, selectedButtonName) } } 2 -> onAddDelay(delaySeconds) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt index a6dd083..b288953 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt @@ -116,7 +116,7 @@ private fun CenteredMessage(paddingValues: PaddingValues, message: String, onRet Button(onClick = onRetry) { Icon(Icons.Filled.Refresh, contentDescription = null) 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) 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)) Button( onClick = onExecute, @@ -247,14 +247,14 @@ private fun CompletedContent( ) { Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(6.dp)) - Text("重新执行") + Text(stringResource(R.string.scene_exec_rerun)) } Button( onClick = onBack, modifier = Modifier.weight(1f).height(48.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)) Column(modifier = Modifier.weight(1f)) { Text( - text = "步骤 $stepNumber: ${entry.action.describe()}", + text = stringResource(R.string.scene_step_format, stepNumber, entry.action.describe()), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, ) @@ -334,11 +334,12 @@ private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) } } +@Composable private fun SceneAction.describe(): String = when (this) { - is SceneAction.SocketPower -> "插座【${deviceName.value}】-> ${powerState.name}" - is SceneAction.VirtualRemoteButton -> "遥控【$remoteDisplayName】-> $buttonDisplayName" - is SceneAction.InfraredKey -> "红外【${deviceName.value}】-> 槽位 $key" - is SceneAction.Delay -> "⏱️ 等待 ${durationSeconds} 秒" - is SceneAction.Notification -> "🔔 提示: $title" + is SceneAction.SocketPower -> stringResource(R.string.scene_action_socket, deviceName.value, powerState.name) + is SceneAction.VirtualRemoteButton -> stringResource(R.string.scene_action_remote, remoteDisplayName, buttonDisplayName) + is SceneAction.InfraredKey -> stringResource(R.string.scene_action_infrared, deviceName.value, key) + is SceneAction.Delay -> stringResource(R.string.scene_action_delay, durationSeconds) + is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt index 8130cbd..5560346 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -305,13 +305,17 @@ private fun SceneCard( ) { if (scene.type == AutomationType.AUTOMATIC) { 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, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { Text( - text = "${scene.actions.size} 个执行动作", + text = stringResource(R.string.scene_summary_actions_only, scene.actions.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 7da15d5..257617c 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -161,6 +161,47 @@ 执行失败 已跳过 + + 时间格式无效(请按 HH:mm 格式输入,如 07:30) + 时间格式无效(请按 HH:mm 格式输入,如 22:00) + ⏰ 定时 + 🌡️ 温度 + 💧 湿度 + 🔌 插座 + 📺 遥控 + ⏱️ 延时 + 🔔 提示 + > 高于 + < 低于 + 开启 + 关闭 + 设定阈值:%1$d °C + 设定阈值:%1$d %% + 要求插座当前状态为: + 未找到虚拟遥控器,将默认使用红外控制器 + 按键名称/槽位 + 按键名称 + 尚未添加触发器,请点击下方按钮添加 + 尚未添加限制条件,请点击下方按钮添加 + 尚未添加执行动作,请点击下方按钮添加 + ⚡ %1$d 个触发器 • %2$d 个动作 + %1$d 个执行动作 + 点击下方按钮立即开始执行 + 重新执行 + 步骤 %1$d:%2$s + 温度 %1$s %2$s°C + 湿度 %1$s %2$s%% + %1$s 状态变为 %2$s + 温度需 %1$s %2$s°C + 湿度需 %1$s %2$s%% + %1$s 处于 %2$s + 时间在 %1$s ~ %2$s 之间 + Zigbee插座控制器 + 红外控制器 + 电源 + 情景完成 + 自动化任务执行完成 + 设备列表 选择需要控制或查看状态的 ABox 终端设备。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9ee38d7..ec69aa7 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -161,6 +161,47 @@ Failed Skipped + + Invalid time format (use HH:mm, e.g. 07:30) + Invalid time format (use HH:mm, e.g. 22:00) + ⏰ Schedule + 🌡️ Temperature + 💧 Humidity + 🔌 Socket + 📺 Remote + ⏱️ Delay + 🔔 Notify + > Above + < Below + Turn ON + Turn OFF + Target threshold: %1$d °C + Target threshold: %1$d %% + Require socket state to be: + No virtual remotes found; using infrared controller by default. + Button Name / Slot + Button Name + No triggers configured. Tap + below to add. + No conditions configured. Tap + below to add. + No actions configured. Tap + below to add. + ⚡ %1$d triggers • %2$d actions + %1$d actions + Tap the button below to run immediately + Re-run + Step %1$d: %2$s + Temp %1$s %2$s°C + Humidity %1$s %2$s%% + %1$s state changed to %2$s + Temp must be %1$s %2$s°C + Humidity must be %1$s %2$s%% + %1$s is %2$s + Time is between %1$s ~ %2$s + Zigbee Smart Socket + Infrared Controller + Power + Scene Completed + Automation task executed successfully + Device List Select an ABox terminal device to control or check status. diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt index bce1d66..81c5bf8 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow 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( private val socketGateway: SocketGateway, private val infraredGateway: InfraredGateway, + private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) }, ) : SceneExecutor { private val running = ConcurrentHashMap() + val executedRemoteButtons = mutableListOf() + val sentNotifications = mutableListOf() override suspend fun execute(scene: Scene): DomainResult { val alreadyRunning = running.putIfAbsent(scene.id, true) != null @@ -277,35 +282,51 @@ class FakeSceneExecutor( action: SceneAction, ): SceneActionExecutionResult { return try { - val result = when (action) { - is SceneAction.SocketPower -> socketGateway.setPower( - profileId = profileId, - deviceName = action.deviceName, - powerState = action.powerState, - ) + when (action) { + is SceneAction.SocketPower -> { + val result = socketGateway.setPower( + profileId = profileId, + deviceName = action.deviceName, + powerState = action.powerState, + ) + toExecutionResult(action, result) + } - is SceneAction.InfraredKey -> infraredGateway.sendKey( - profileId = profileId, - deviceName = action.deviceName, - key = action.key, - ) + is SceneAction.InfraredKey -> { + val result = infraredGateway.sendKey( + profileId = profileId, + deviceName = action.deviceName, + key = action.key, + ) + toExecutionResult(action, result) + } - is SceneAction.VirtualRemoteButton, - is SceneAction.Delay, - is SceneAction.Notification -> DomainResult.Success(Unit) - } - when (result) { - is DomainResult.Success -> SceneActionExecutionResult( - action = action, - outcome = SceneActionOutcome.SUCCESS, - error = null, - ) + is SceneAction.VirtualRemoteButton -> { + executedRemoteButtons.add(action) + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + } - is DomainResult.Failure -> SceneActionExecutionResult( - action = action, - outcome = SceneActionOutcome.FAILURE, - error = result.error, - ) + 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 @@ -317,4 +338,20 @@ class FakeSceneExecutor( ) } } + + private fun toExecutionResult(action: SceneAction, result: DomainResult<*>): SceneActionExecutionResult { + return when (result) { + is DomainResult.Success -> SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + + is DomainResult.Failure -> SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.FAILURE, + error = result.error, + ) + } + } } diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt index 7e2551c..b2cb7d5 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt @@ -116,6 +116,46 @@ class FakeSceneExecutorTest { assertFalse(executionResult.allSucceeded) } + @Test + fun executesDelayVirtualRemoteAndNotification() = runTest { + val delays = mutableListOf() + 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): Scene = Scene( id = SceneId("${profileId.value}:scene-test"), profileId = profileId,