From 9ac1616046e4b623a3c3ffa96258df829bc0d9ae Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 16:15:31 +0800 Subject: [PATCH] feat(scene): refactor scene automation into modular trigger-action system - Support manual one-tap commands and automatic condition-based rules - Add modular triggers (time, temperature, humidity, device state) - Add modular conditions (environment thresholds, socket state, time range) - Upgrade actions to support virtual remotes, sockets, delays, and notifications - Support sequential/parallel concurrency mode and error execution strategies - Refactor SceneListScreen with filter tabs, quick execution, and enable switches - Refactor SceneEditorScreen with modular builders and preset pickers - Enhance SceneExecutionScreen with step timing, skips, and rerun capability - Add comprehensive domain and UI ViewModel unit tests --- .../abox/manager/di/SceneIntegrationModule.kt | 8 +- .../abox/manager/ui/ABoxManagerApp.kt | 3 +- .../manager/ui/scene/SceneEditorScreen.kt | 1016 ++++++++++++++--- .../manager/ui/scene/SceneEditorViewModel.kt | 237 +++- .../manager/ui/scene/SceneExecutionScreen.kt | 219 +++- .../abox/manager/ui/scene/SceneListScreen.kt | 326 +++++- .../manager/ui/scene/SceneListViewModel.kt | 53 +- .../abox/manager/ui/scene/SceneNavigation.kt | 28 +- .../src/main/res/values-zh-rCN/strings.xml | 130 ++- android/app/src/main/res/values/strings.xml | 130 ++- .../ui/scene/SceneEditorViewModelTest.kt | 59 +- .../ui/scene/SceneListViewModelTest.kt | 95 +- .../manager/domain/DefaultSceneExecutor.kt | 127 ++- .../manager/domain/InMemorySceneRepository.kt | 49 +- .../com/flagship/abox/manager/domain/Scene.kt | 164 ++- .../abox/manager/domain/SceneExecution.kt | 10 +- .../domain/DefaultSceneExecutorTest.kt | 73 ++ .../testing/fakes/FakeSceneRepository.kt | 28 +- 18 files changed, 2345 insertions(+), 410 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt index ed1aadb..24df299 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt @@ -6,6 +6,7 @@ import com.flagship.abox.manager.domain.InfraredGateway import com.flagship.abox.manager.domain.SceneExecutor import com.flagship.abox.manager.domain.SceneRepository import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -27,5 +28,10 @@ object SceneIntegrationModule { fun provideSceneExecutor( socketGateway: SocketGateway, infraredGateway: InfraredGateway, - ): SceneExecutor = DefaultSceneExecutor(socketGateway, infraredGateway) + sendVirtualRemoteAction: SendVirtualRemoteAction, + ): SceneExecutor = DefaultSceneExecutor( + socketGateway = socketGateway, + infraredGateway = infraredGateway, + sendVirtualRemoteAction = sendVirtualRemoteAction, + ) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 7928549..42bda6b 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -130,7 +130,8 @@ private fun AuthenticatedNavHost( }, scenesSlot = { SceneListScreen( - onAddScene = { navController.navigate(SceneRoutes.EDITOR) }, + onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) }, + onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) }, onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) }, onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, ) 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 d2ae3c5..0a98780 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 @@ -1,64 +1,119 @@ package com.flagship.abox.manager.ui.scene +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.AssistChip +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Thermostat +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.R +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.ExecutionStrategy import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneCondition 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.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import kotlinx.coroutines.launch + +private val PRESET_COLORS = listOf( + "#3F51B5", "#26A69A", "#FF8A65", "#7986CB", + "#E91E63", "#9C27B0", "#4CAF50", "#FF9800", + "#00BCD4", "#607D8B", "#F44336", "#673AB7", +) @OptIn(ExperimentalMaterial3Api::class) @Composable fun SceneEditorScreen( - sceneId: com.flagship.abox.manager.domain.SceneId?, + sceneId: SceneId?, + initialType: AutomationType? = null, onBack: () -> Unit, onSaved: () -> Unit, viewModel: SceneEditorViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - androidx.compose.runtime.LaunchedEffect(state.isSaved) { - if (state.isSaved) onSaved() + var showTriggerDialog by remember { mutableStateOf(false) } + var showConditionDialog by remember { mutableStateOf(false) } + var showActionDialog by remember { mutableStateOf(false) } + var expandAdvanced by remember { mutableStateOf(false) } + var expandOther by remember { mutableStateOf(false) } + + LaunchedEffect(sceneId) { + viewModel.initialize(sceneId, initialType) } - androidx.compose.runtime.LaunchedEffect(sceneId) { - viewModel.initialize(sceneId) + + LaunchedEffect(state.isSaved) { + if (state.isSaved) { + onSaved() + } } Scaffold( @@ -68,13 +123,27 @@ fun SceneEditorScreen( Text( stringResource( if (state.isEditing) R.string.scene_editor_edit_title - else R.string.scene_editor_new_title, + else if (state.type == AutomationType.AUTOMATIC) R.string.scene_editor_auto_title + else R.string.scene_editor_manual_title, ), + fontWeight = FontWeight.SemiBold, ) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.scene_back)) + } + }, + actions = { + IconButton( + onClick = viewModel::save, + enabled = state.isValid && !state.isSaving, + ) { + Icon( + Icons.Filled.Check, + contentDescription = stringResource(R.string.scene_save_action), + tint = if (state.isValid && !state.isSaving) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) } }, ) @@ -84,75 +153,483 @@ fun SceneEditorScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - item { - OutlinedTextField( - value = state.name, - onValueChange = viewModel::updateName, - label = { Text(stringResource(R.string.scene_name_label)) }, - singleLine = true, + item(key = "title_card") { + Card( modifier = Modifier.fillMaxWidth(), - ) - } - item { - Text(stringResource(R.string.scene_icon_label), style = MaterialTheme.typography.titleSmall) - Spacer(Modifier.size(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SceneIcon.entries.forEach { icon -> - FilterChip( - selected = state.icon == icon, - onClick = { viewModel.updateIcon(icon) }, - label = { Text(icon.displayLabel()) }, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = state.name, + onValueChange = viewModel::updateName, + label = { Text(stringResource(R.string.scene_name_label)) }, + placeholder = { Text(stringResource(R.string.scene_name_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + OutlinedTextField( + value = state.description, + onValueChange = viewModel::updateDescription, + label = { Text(stringResource(R.string.scene_description_label)) }, + placeholder = { Text(stringResource(R.string.scene_description_hint)) }, + singleLine = false, + maxLines = 2, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), ) } } } - item { - OutlinedTextField( - value = state.colorHex, - onValueChange = viewModel::updateColor, - label = { Text(stringResource(R.string.scene_color_label)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + + if (state.type == AutomationType.AUTOMATIC) { + item(key = "triggers_card") { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Schedule, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.scene_section_triggers), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = "${state.triggers.size}", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + } + + state.triggers.forEach { trigger -> + TriggerItemRow(trigger = trigger, onRemove = { viewModel.removeTrigger(trigger.id) }) + } + + OutlinedButton( + onClick = { showTriggerDialog = true }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Text(stringResource(R.string.scene_add_trigger)) + } + } + } + } + } + + item(key = "conditions_card") { + Card( modifier = Modifier.fillMaxWidth(), - ) + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Thermostat, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.scene_section_conditions), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Text( + text = "${state.conditions.size}", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + } + + state.conditions.forEach { condition -> + ConditionItemRow(condition = condition, onRemove = { viewModel.removeCondition(condition.id) }) + } + + OutlinedButton( + onClick = { showConditionDialog = true }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Text(stringResource(R.string.scene_add_condition)) + } + } + } } - item { - Text( - stringResource(R.string.scene_actions_label, state.actions.size), - style = MaterialTheme.typography.titleSmall, - ) + + item(key = "actions_header") { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.scene_section_actions), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = when (state.concurrencyMode) { + ConcurrencyMode.SEQUENTIAL -> stringResource(R.string.scene_concurrency_sync) + ConcurrencyMode.PARALLEL -> stringResource(R.string.scene_concurrency_async) + }, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + } + + if (state.actions.isEmpty()) { + Text( + text = "暂未添加动作,请点击下方按钮添加", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } - items(state.actions, key = { it.id.value }) { action -> - ActionRow( + + itemsIndexed(state.actions, key = { _, it -> it.id.value }) { index, action -> + ActionCardRow( action = action, - index = state.actions.indexOf(action), + index = index, total = state.actions.size, onMoveUp = { i -> viewModel.moveAction(i, i - 1) }, onMoveDown = { i -> viewModel.moveAction(i, i + 1) }, onRemove = { viewModel.removeAction(action.id) }, ) } - item { AddSocketActionSection(viewModel) } - item { AddInfraredActionSection(viewModel) } - item { - state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) } + + item(key = "add_action_btn") { + Button( + onClick = { showActionDialog = true }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Text(stringResource(R.string.scene_add_action_btn), fontWeight = FontWeight.SemiBold) + } + } + + item(key = "advanced_options") { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expandAdvanced = !expandAdvanced }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "★ " + stringResource(R.string.scene_section_advanced), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Icon( + if (expandAdvanced) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = null, + ) + } + + AnimatedVisibility(visible = expandAdvanced) { + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringResource(R.string.scene_concurrency_mode), style = MaterialTheme.typography.labelMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = state.concurrencyMode == ConcurrencyMode.SEQUENTIAL, + onClick = { viewModel.updateConcurrencyMode(ConcurrencyMode.SEQUENTIAL) }, + label = { Text(stringResource(R.string.scene_concurrency_sync)) }, + ) + FilterChip( + selected = state.concurrencyMode == ConcurrencyMode.PARALLEL, + onClick = { viewModel.updateConcurrencyMode(ConcurrencyMode.PARALLEL) }, + label = { Text(stringResource(R.string.scene_concurrency_async)) }, + ) + } + + Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.labelMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = state.strategy == ExecutionStrategy.CONTINUE_ON_ERROR, + onClick = { viewModel.updateStrategy(ExecutionStrategy.CONTINUE_ON_ERROR) }, + label = { Text(stringResource(R.string.scene_policy_continue)) }, + ) + FilterChip( + selected = state.strategy == ExecutionStrategy.ABORT_ON_ERROR, + onClick = { viewModel.updateStrategy(ExecutionStrategy.ABORT_ON_ERROR) }, + label = { Text(stringResource(R.string.scene_policy_abort)) }, + ) + } + } + } + } + } + } + + item(key = "other_options") { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expandOther = !expandOther }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "🎨 " + stringResource(R.string.scene_section_other), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Icon( + if (expandOther) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = null, + ) + } + + AnimatedVisibility(visible = expandOther) { + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text(stringResource(R.string.scene_appearance_icon), style = MaterialTheme.typography.labelMedium) + IconPickerRow( + selectedIcon = state.icon, + onSelect = viewModel::updateIcon, + ) + + Text(stringResource(R.string.scene_appearance_color), style = MaterialTheme.typography.labelMedium) + ColorPickerRow( + selectedColorHex = state.colorHex, + onSelect = viewModel::updateColor, + ) + + if (state.type == AutomationType.AUTOMATIC) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(stringResource(R.string.scene_enable_rule), style = MaterialTheme.typography.bodyMedium) + Switch( + checked = state.enabled, + onCheckedChange = viewModel::updateEnabled, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(stringResource(R.string.scene_enable_logging), style = MaterialTheme.typography.bodyMedium) + Switch( + checked = state.enableLogging, + onCheckedChange = viewModel::updateLogging, + ) + } + } + } + } + } + } + + item(key = "save_section") { + state.errorMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(8.dp)) + } Button( onClick = viewModel::save, enabled = state.isValid && !state.isSaving, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(12.dp), ) { - Text(stringResource(R.string.scene_save_action)) + Text(stringResource(R.string.scene_save_action), fontWeight = FontWeight.Bold) } } } } + + 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) + } + showTriggerDialog = false + }, + ) + } + + if (showConditionDialog) { + AddConditionDialog( + onDismiss = { showConditionDialog = false }, + onAddTemp = { op, temp -> + viewModel.addTemperatureCondition("温湿度传感器", op, temp) + showConditionDialog = false + }, + onAddHumidity = { op, hum -> + viewModel.addHumidityCondition("温湿度传感器", op, hum) + showConditionDialog = false + }, + onAddSocket = { powerState -> + viewModel.addSocketPowerCondition("Zigbee插座控制器", powerState) + showConditionDialog = false + }, + onAddTimeRange = { start, end -> + viewModel.addTimeRangeCondition(start, end) + showConditionDialog = false + }, + ) + } + + if (showActionDialog) { + AddActionDialog( + availableRemotes = state.availableRemotes, + onDismiss = { showActionDialog = false }, + viewModel = viewModel, + onAddSocket = { name, power -> + viewModel.addSocketAction(name, power) + showActionDialog = false + }, + onAddRemote = { remoteId, remoteName, btnId, btnName -> + viewModel.addVirtualRemoteAction(remoteId, remoteName, btnId, btnName) + showActionDialog = false + }, + onAddDelay = { seconds -> + viewModel.addDelayAction(seconds) + showActionDialog = false + }, + onAddNotify = { title, msg -> + viewModel.addNotificationAction(title, msg) + showActionDialog = false + }, + ) + } +} + +@Composable +private fun TriggerItemRow(trigger: SceneTrigger, onRemove: () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + 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}") + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(icon, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.width(8.dp)) + Text(desc, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + } + } + } } @Composable -private fun ActionRow( +private fun ConditionItemRow(condition: SceneCondition, onRemove: () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + 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} 之间") + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(icon, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.width(8.dp)) + Text(desc, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + } + } + } +} + +@Composable +private fun ActionCardRow( action: SceneAction, index: Int, total: Int, @@ -160,122 +637,369 @@ private fun ActionRow( onMoveDown: (Int) -> Unit, onRemove: () -> Unit, ) { - Card(modifier = Modifier.fillMaxWidth()) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { Row( modifier = Modifier .fillMaxWidth() .padding(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = when (action) { - is SceneAction.SocketPower -> stringResource( - R.string.scene_action_socket, - action.deviceName.value, - action.powerState.name, - ) - is SceneAction.InfraredKey -> stringResource( - R.string.scene_action_infrared, - action.deviceName.value, - action.key, - ) - }, - style = MaterialTheme.typography.bodyMedium, - ) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(28.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text("${index + 1}", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } } - IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) { + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + val title = when (action) { + is SceneAction.SocketPower -> stringResource(R.string.scene_action_socket, action.deviceName.value, action.powerState.name) + is SceneAction.VirtualRemoteButton -> stringResource(R.string.scene_action_remote, action.remoteDisplayName, action.buttonDisplayName) + is SceneAction.InfraredKey -> stringResource(R.string.scene_action_infrared, action.deviceName.value, action.key) + is SceneAction.Delay -> stringResource(R.string.scene_action_delay, action.durationSeconds) + is SceneAction.Notification -> stringResource(R.string.scene_action_notification, action.title) + } + Text(text = title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + IconButton(onClick = { onMoveUp(index) }, enabled = index > 0, modifier = Modifier.size(32.dp)) { Text("↑", style = MaterialTheme.typography.titleMedium) } - IconButton(onClick = { onMoveDown(index) }, enabled = index < total - 1) { + IconButton(onClick = { onMoveDown(index) }, enabled = index < total - 1, modifier = Modifier.size(32.dp)) { Text("↓", style = MaterialTheme.typography.titleMedium) } - IconButton(onClick = onRemove) { - Icon(Icons.Filled.Delete, contentDescription = null) + IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) } } } } +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun AddSocketActionSection(viewModel: SceneEditorViewModel) { - var deviceName by remember { mutableStateOf("") } - var powerOn by remember { mutableStateOf(true) } - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - stringResource(R.string.scene_add_socket_action), - style = MaterialTheme.typography.titleSmall, - ) - OutlinedTextField( - value = deviceName, - onValueChange = { deviceName = it }, - label = { Text(stringResource(R.string.scene_device_name_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SocketPowerState.entries.forEach { state -> - FilterChip( - selected = powerOn == (state == SocketPowerState.ON), - onClick = { powerOn = state == SocketPowerState.ON }, - label = { Text(state.name) }, - ) +private fun IconPickerRow(selectedIcon: SceneIcon, onSelect: (SceneIcon) -> Unit) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SceneIcon.entries.forEach { icon -> + Surface( + shape = RoundedCornerShape(8.dp), + color = if (selectedIcon == icon) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { onSelect(icon) } + .border( + width = if (selectedIcon == icon) 2.dp else 1.dp, + color = if (selectedIcon == icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(8.dp), + ), + ) { + Box(contentAlignment = Alignment.Center) { + Text(icon.displayEmoji(), style = MaterialTheme.typography.titleMedium) + } } } - AssistChip( - onClick = { - viewModel.addSocketAction( - deviceName, - if (powerOn) SocketPowerState.ON else SocketPowerState.OFF, - ) - deviceName = "" - }, - label = { Text(stringResource(R.string.scene_add_action)) }, - ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ColorPickerRow(selectedColorHex: String, onSelect: (String) -> Unit) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + PRESET_COLORS.forEach { hex -> + val color = SceneColor(hex).toColorOrNull() ?: Color.Gray + Box( + modifier = Modifier + .size(34.dp) + .background(color, shape = CircleShape) + .clip(CircleShape) + .clickable { onSelect(hex) } + .border( + width = if (selectedColorHex.equals(hex, ignoreCase = true)) 3.dp else 1.dp, + color = if (selectedColorHex.equals(hex, ignoreCase = true)) MaterialTheme.colorScheme.onSurface else Color.Transparent, + shape = CircleShape, + ), + ) + } } } @Composable -private fun AddInfraredActionSection(viewModel: SceneEditorViewModel) { - var deviceName by remember { mutableStateOf("") } - var key by remember { mutableStateOf("") } +private fun AddTriggerDialog( + onDismiss: () -> Unit, + onAdd: (type: Int, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit, +) { + var selectedType by remember { mutableIntStateOf(0) } + var timeInput by remember { mutableStateOf("07:30") } + var tempThreshold by remember { mutableDoubleStateOf(28.0) } + var humThreshold by remember { mutableDoubleStateOf(40.0) } + var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - stringResource(R.string.scene_add_infrared_action), - style = MaterialTheme.typography.titleSmall, - ) - OutlinedTextField( - value = deviceName, - onValueChange = { deviceName = it }, - label = { Text(stringResource(R.string.scene_device_name_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = key, - onValueChange = { key = it }, - label = { Text(stringResource(R.string.scene_infrared_key_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - AssistChip( - onClick = { - viewModel.addInfraredAction(deviceName, key) - deviceName = "" - key = "" - }, - label = { Text(stringResource(R.string.scene_add_action)) }, - ) + 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("💧 湿度") }) + } + when (selectedType) { + 0 -> { + OutlinedTextField( + value = timeInput, + onValueChange = { timeInput = it }, + label = { Text(stringResource(R.string.scene_trigger_time_label)) }, + singleLine = true, + 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("< 低于") }) + } + if (selectedType == 1) { + Text("设定阈值: ${tempThreshold.toInt()} °C") + Slider(value = tempThreshold.toFloat(), onValueChange = { tempThreshold = it.toDouble() }, valueRange = 10f..40f) + } else { + Text("设定阈值: ${humThreshold.toInt()} %") + Slider(value = humThreshold.toFloat(), onValueChange = { humThreshold = it.toDouble() }, valueRange = 10f..90f) + } + } + } + } + }, + confirmButton = { + Button(onClick = { onAdd(selectedType, timeInput, tempThreshold, humThreshold, operator) }) { + Text(stringResource(R.string.scene_add_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.scene_delete_cancel)) } + }, + ) +} + +@Composable +private fun AddConditionDialog( + onDismiss: () -> Unit, + onAddTemp: (ComparisonOperator, Double) -> Unit, + onAddHumidity: (ComparisonOperator, Double) -> Unit, + onAddSocket: (SocketPowerState) -> Unit, + onAddTimeRange: (String, String) -> Unit, +) { + var selectedType by remember { mutableIntStateOf(0) } + var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) } + var tempVal by remember { mutableDoubleStateOf(26.0) } + var humVal by remember { mutableDoubleStateOf(50.0) } + var socketPower by remember { mutableStateOf(SocketPowerState.OFF) } + var startTime by remember { mutableStateOf("22:00") } + var endTime by remember { mutableStateOf("07:00") } + + 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("🕒 时间") }) + } + 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("< 低于") }) + } + Text("设定温度: ${tempVal.toInt()} °C") + 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("< 低于") }) + } + Text("设定湿度: ${humVal.toInt()} %") + Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f) + } + 2 -> { + Text("要求插座当前状态为:") + 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("关闭") }) + } + } + 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) + } + } + } + }, + confirmButton = { + Button(onClick = { + when (selectedType) { + 0 -> onAddTemp(operator, tempVal) + 1 -> onAddHumidity(operator, humVal) + 2 -> onAddSocket(socketPower) + 3 -> onAddTimeRange(startTime, endTime) + } + }) { + Text(stringResource(R.string.scene_add_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.scene_delete_cancel)) } + }, + ) +} + +@Composable +private fun AddActionDialog( + availableRemotes: List, + onDismiss: () -> Unit, + viewModel: SceneEditorViewModel, + onAddSocket: (deviceName: String, powerState: SocketPowerState) -> Unit, + onAddRemote: (remoteId: String, remoteName: String, btnId: String, btnName: String) -> Unit, + onAddDelay: (seconds: Int) -> Unit, + onAddNotify: (title: String, msg: String) -> Unit, +) { + var actionType by remember { mutableIntStateOf(0) } + var socketName by remember { mutableStateOf("Zigbee插座控制器") } + 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 delaySeconds by remember { mutableIntStateOf(3) } + var notifyTitle by remember { mutableStateOf("情景完成") } + var notifyMsg by remember { mutableStateOf("自动化任务执行完成") } + + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(availableRemotes, selectedRemoteIndex) { + if (availableRemotes.isNotEmpty() && selectedRemoteIndex in availableRemotes.indices) { + val remote = availableRemotes[selectedRemoteIndex] + coroutineScope.launch { + val bindings = viewModel.getKeyBindings(remote.id) + keyBindings = bindings + if (bindings.isNotEmpty()) { + selectedButtonId = bindings[0].buttonId + selectedButtonName = bindings[0].displayName.ifBlank { bindings[0].buttonId } + } + } + } } -} -private fun SceneIcon.displayLabel(): String = when (this) { - SceneIcon.HOME -> "Home" - SceneIcon.OFFICE -> "Office" - SceneIcon.NIGHT -> "Night" - SceneIcon.MOVIE -> "Movie" - SceneIcon.AWAY -> "Away" - SceneIcon.CUSTOM -> "Custom" + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.scene_dialog_add_action_title)) }, + 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("🔔 提示") }) + } + 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_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("关闭") }) + } + } + 1 -> { + if (availableRemotes.isEmpty()) { + Text("未找到虚拟遥控器,将默认使用红外控制器") + OutlinedTextField(value = selectedButtonId, onValueChange = { selectedButtonId = it }, label = { Text("按键名称/槽位") }, singleLine = true) + } else { + Text(stringResource(R.string.scene_select_virtual_remote)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + availableRemotes.forEachIndexed { idx, remote -> + FilterChip( + selected = selectedRemoteIndex == idx, + onClick = { selectedRemoteIndex = idx }, + label = { Text(remote.displayName) }, + ) + } + } + Text(stringResource(R.string.scene_select_remote_button)) + if (keyBindings.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + keyBindings.take(4).forEach { binding -> + val label = binding.displayName.ifBlank { binding.buttonId } + FilterChip( + selected = selectedButtonId == binding.buttonId, + onClick = { + selectedButtonId = binding.buttonId + selectedButtonName = label + }, + label = { Text(label) }, + ) + } + } + } else { + OutlinedTextField(value = selectedButtonName, onValueChange = { selectedButtonName = it; selectedButtonId = it }, label = { Text("按键名称") }, singleLine = true) + } + } + } + 2 -> { + Text(stringResource(R.string.scene_delay_duration_label) + ": $delaySeconds 秒") + Slider( + value = delaySeconds.toFloat(), + onValueChange = { delaySeconds = it.toInt() }, + valueRange = 1f..30f, + steps = 29, + ) + } + 3 -> { + OutlinedTextField(value = notifyTitle, onValueChange = { notifyTitle = it }, label = { Text(stringResource(R.string.scene_notification_title_label)) }, singleLine = true) + OutlinedTextField(value = notifyMsg, onValueChange = { notifyMsg = it }, label = { Text(stringResource(R.string.scene_notification_msg_label)) }, singleLine = true) + } + } + } + }, + confirmButton = { + Button(onClick = { + when (actionType) { + 0 -> onAddSocket(socketName, socketPower) + 1 -> { + val remote = availableRemotes.getOrNull(selectedRemoteIndex) + if (remote != null) { + onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName) + } else { + onAddRemote("default-ir", "红外控制器", selectedButtonId, selectedButtonName) + } + } + 2 -> onAddDelay(delaySeconds) + 3 -> onAddNotify(notifyTitle, notifyMsg) + } + }) { + Text(stringResource(R.string.scene_add_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.scene_delete_cancel)) } + }, + ) } 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 4a4f695..66127a0 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 @@ -2,32 +2,54 @@ package com.flagship.abox.manager.ui.scene import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +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.DomainResult +import com.flagship.abox.manager.domain.ExecutionStrategy import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneAction import com.flagship.abox.manager.domain.SceneActionId import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneCondition +import com.flagship.abox.manager.domain.SceneConditionId import com.flagship.abox.manager.domain.SceneIcon import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SceneTrigger +import com.flagship.abox.manager.domain.SceneTriggerId import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository import dagger.hilt.android.lifecycle.HiltViewModel import java.util.UUID import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class SceneEditorState( val sceneId: SceneId? = null, + val type: AutomationType = AutomationType.MANUAL, val name: String = "", + val description: String = "", + val enabled: Boolean = true, val icon: SceneIcon = SceneIcon.HOME, - val colorHex: String = "#FF8A65", + val colorHex: String = "#3F51B5", + val triggers: List = emptyList(), + val conditions: List = emptyList(), val actions: List = emptyList(), + val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR, + val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL, + val enableLogging: Boolean = true, + val availableRemotes: List = emptyList(), val isSaving: Boolean = false, val isSaved: Boolean = false, val errorMessage: String? = null, @@ -39,15 +61,40 @@ data class SceneEditorState( @HiltViewModel class SceneEditorViewModel @Inject constructor( private val sceneRepository: SceneRepository, + private val virtualRemoteRepository: VirtualRemoteRepository, private val profileStore: ProviderProfileStore, ) : ViewModel() { private val _state = MutableStateFlow(SceneEditorState()) val state: StateFlow = _state.asStateFlow() - fun initialize(sceneId: SceneId?) { + init { + observeAvailableRemotes() + } + + private fun observeAvailableRemotes() { + viewModelScope.launch { + profileStore.observeActiveProfile().collectLatest { profile -> + if (profile != null) { + virtualRemoteRepository.observeRemotes(profile.id, DeviceName("红外控制器")).collectLatest { res -> + if (res is DomainResult.Success) { + _state.update { it.copy(availableRemotes = res.value) } + } + } + } + } + } + } + + fun initialize(sceneId: SceneId?, initialType: AutomationType? = null) { if (sceneId == null) { - _state.value = SceneEditorState() + _state.update { + SceneEditorState( + type = initialType ?: AutomationType.MANUAL, + colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5", + availableRemotes = it.availableRemotes, + ) + } return } viewModelScope.launch { @@ -58,13 +105,23 @@ class SceneEditorViewModel @Inject constructor( } val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value if (scene != null) { - _state.value = SceneEditorState( - sceneId = scene.id, - name = scene.name, - icon = scene.icon, - colorHex = scene.color.hex, - actions = scene.actions, - ) + _state.update { + it.copy( + sceneId = scene.id, + type = scene.type, + name = scene.name, + description = scene.description, + enabled = scene.enabled, + icon = scene.icon, + colorHex = scene.color.hex, + triggers = scene.triggers, + conditions = scene.conditions, + actions = scene.actions, + strategy = scene.strategy, + concurrencyMode = scene.concurrencyMode, + enableLogging = scene.enableLogging, + ) + } } else { _state.update { it.copy(errorMessage = "Scene not found") } } @@ -72,11 +129,104 @@ class SceneEditorViewModel @Inject constructor( } fun updateName(name: String) = _state.update { it.copy(name = name, errorMessage = null) } - + fun updateDescription(desc: String) = _state.update { it.copy(description = desc) } + fun updateType(type: AutomationType) = _state.update { it.copy(type = type) } + fun updateEnabled(enabled: Boolean) = _state.update { it.copy(enabled = enabled) } fun updateIcon(icon: SceneIcon) = _state.update { it.copy(icon = icon) } - fun updateColor(hex: String) = _state.update { it.copy(colorHex = hex) } + fun updateStrategy(strategy: ExecutionStrategy) = _state.update { it.copy(strategy = strategy) } + fun updateConcurrencyMode(mode: ConcurrencyMode) = _state.update { it.copy(concurrencyMode = mode) } + fun updateLogging(enabled: Boolean) = _state.update { it.copy(enableLogging = enabled) } + // --- Triggers --- + fun addTimeTrigger(timeOfDay: String, daysOfWeek: Set = emptySet()) { + val trigger = SceneTrigger.Time( + id = SceneTriggerId(UUID.randomUUID().toString()), + timeOfDay = timeOfDay, + daysOfWeek = daysOfWeek, + label = "定时 $timeOfDay", + ) + _state.update { it.copy(triggers = it.triggers + trigger) } + } + + fun addTemperatureTrigger(deviceName: String, operator: ComparisonOperator, threshold: Double) { + val trigger = SceneTrigger.Temperature( + id = SceneTriggerId(UUID.randomUUID().toString()), + deviceName = DeviceName(deviceName), + operator = operator, + thresholdCelsius = threshold, + label = "$deviceName 温度 ${operator.symbol} $threshold°C", + ) + _state.update { it.copy(triggers = it.triggers + trigger) } + } + + fun addHumidityTrigger(deviceName: String, operator: ComparisonOperator, threshold: Double) { + val trigger = SceneTrigger.Humidity( + id = SceneTriggerId(UUID.randomUUID().toString()), + deviceName = DeviceName(deviceName), + operator = operator, + thresholdPercent = threshold, + label = "$deviceName 湿度 ${operator.symbol} $threshold%", + ) + _state.update { it.copy(triggers = it.triggers + trigger) } + } + + fun addDeviceStateTrigger(deviceName: String, expectedState: String) { + val trigger = SceneTrigger.DeviceState( + id = SceneTriggerId(UUID.randomUUID().toString()), + deviceName = DeviceName(deviceName), + expectedState = expectedState, + label = "$deviceName 状态变为 $expectedState", + ) + _state.update { it.copy(triggers = it.triggers + trigger) } + } + + fun removeTrigger(triggerId: SceneTriggerId) = + _state.update { it.copy(triggers = it.triggers.filterNot { t -> t.id == triggerId }) } + + // --- Conditions --- + fun addTemperatureCondition(deviceName: String, operator: ComparisonOperator, threshold: Double) { + val cond = SceneCondition.Temperature( + id = SceneConditionId(UUID.randomUUID().toString()), + deviceName = DeviceName(deviceName), + operator = operator, + thresholdCelsius = threshold, + ) + _state.update { it.copy(conditions = it.conditions + cond) } + } + + fun addHumidityCondition(deviceName: String, operator: ComparisonOperator, threshold: Double) { + val cond = SceneCondition.Humidity( + id = SceneConditionId(UUID.randomUUID().toString()), + deviceName = DeviceName(deviceName), + operator = operator, + thresholdPercent = threshold, + ) + _state.update { it.copy(conditions = it.conditions + cond) } + } + + fun addSocketPowerCondition(deviceName: String, powerState: SocketPowerState) { + val cond = SceneCondition.SocketPower( + id = SceneConditionId(UUID.randomUUID().toString()), + deviceName = DeviceName(deviceName), + expectedState = powerState, + ) + _state.update { it.copy(conditions = it.conditions + cond) } + } + + fun addTimeRangeCondition(startTime: String, endTime: String) { + val cond = SceneCondition.TimeRange( + id = SceneConditionId(UUID.randomUUID().toString()), + startTime = startTime, + endTime = endTime, + ) + _state.update { it.copy(conditions = it.conditions + cond) } + } + + fun removeCondition(conditionId: SceneConditionId) = + _state.update { it.copy(conditions = it.conditions.filterNot { c -> c.id == conditionId }) } + + // --- Actions --- fun addSocketAction(deviceName: String, powerState: SocketPowerState) { if (deviceName.isBlank()) return viewModelScope.launch { @@ -91,6 +241,26 @@ class SceneEditorViewModel @Inject constructor( } } + fun addVirtualRemoteAction( + remoteId: String, + remoteDisplayName: String, + buttonId: String, + buttonDisplayName: String, + ) { + viewModelScope.launch { + val profile = profileStore.activeProfile() ?: return@launch + val action = SceneAction.VirtualRemoteButton( + id = SceneActionId(UUID.randomUUID().toString()), + profileId = profile.id, + remoteId = remoteId, + remoteDisplayName = remoteDisplayName, + buttonId = buttonId, + buttonDisplayName = buttonDisplayName, + ) + _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + } + } + fun addInfraredAction(deviceName: String, key: String) { if (deviceName.isBlank() || key.isBlank()) return viewModelScope.launch { @@ -105,6 +275,33 @@ class SceneEditorViewModel @Inject constructor( } } + fun addDelayAction(seconds: Int) { + if (seconds <= 0) return + viewModelScope.launch { + val profile = profileStore.activeProfile() ?: return@launch + val action = SceneAction.Delay( + id = SceneActionId(UUID.randomUUID().toString()), + profileId = profile.id, + durationSeconds = seconds, + ) + _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + } + } + + fun addNotificationAction(title: String, message: String) { + if (title.isBlank()) return + viewModelScope.launch { + val profile = profileStore.activeProfile() ?: return@launch + val action = SceneAction.Notification( + id = SceneActionId(UUID.randomUUID().toString()), + profileId = profile.id, + title = title, + message = message, + ) + _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + } + } + fun removeAction(actionId: SceneActionId) = _state.update { it.copy(actions = it.actions.filterNot { a -> a.id == actionId }) } @@ -115,6 +312,13 @@ class SceneEditorViewModel @Inject constructor( _state.update { it.copy(actions = updated) } } + suspend fun getKeyBindings(remoteId: VirtualRemoteId): List { + return when (val res = virtualRemoteRepository.getKeyBindings(remoteId)) { + is DomainResult.Success -> res.value + is DomainResult.Failure -> emptyList() + } + } + fun save() { val state = _state.value if (!state.isValid || state.isSaving) return @@ -129,9 +333,17 @@ class SceneEditorViewModel @Inject constructor( id = state.sceneId ?: SceneId("${profile.id.value}:scene-${UUID.randomUUID()}"), profileId = profile.id, name = state.name.trim(), + description = state.description.trim(), + type = state.type, + enabled = state.enabled, icon = state.icon, color = SceneColor(state.colorHex), + triggers = state.triggers, + conditions = state.conditions, actions = state.actions, + strategy = state.strategy, + concurrencyMode = state.concurrencyMode, + enableLogging = state.enableLogging, ) val result = if (state.isEditing) { sceneRepository.updateScene(profile.id, scene) @@ -151,3 +363,4 @@ class SceneEditorViewModel @Inject constructor( } } + 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 47ef46c..c21663c 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 @@ -1,28 +1,39 @@ package com.flagship.abox.manager.ui.scene import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.HourglassDisabled import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -30,8 +41,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -39,21 +50,25 @@ import com.flagship.abox.manager.R import com.flagship.abox.manager.domain.SceneAction import com.flagship.abox.manager.domain.SceneActionExecutionResult import com.flagship.abox.manager.domain.SceneActionOutcome +import com.flagship.abox.manager.domain.SceneExecutionResult +import com.flagship.abox.manager.domain.SceneId @OptIn(ExperimentalMaterial3Api::class) @Composable fun SceneExecutionScreen( - sceneId: com.flagship.abox.manager.domain.SceneId, + sceneId: SceneId, onBack: () -> Unit, viewModel: SceneExecutionViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(sceneId) { viewModel.initialize(sceneId) } + LaunchedEffect(sceneId) { + viewModel.initialize(sceneId) + } Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.scene_execution_title)) }, + title = { Text(stringResource(R.string.scene_execution_title), fontWeight = FontWeight.SemiBold) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) @@ -64,29 +79,28 @@ fun SceneExecutionScreen( ) { innerPadding -> when (val state = uiState) { SceneExecutionUiState.Loading -> CenteredLoading(innerPadding) - is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message) + is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message, onRetry = { viewModel.initialize(sceneId) }) is SceneExecutionUiState.Ready -> ReadyContent(innerPadding, state.scene.name, viewModel::execute) is SceneExecutionUiState.Running -> RunningContent(innerPadding, state.scene.name) - is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack) + is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack, onRerun = viewModel::execute) } } } @Composable private fun CenteredLoading(paddingValues: PaddingValues) { - Row( + Box( modifier = Modifier .fillMaxSize() .padding(paddingValues), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } } @Composable -private fun CenteredMessage(paddingValues: PaddingValues, message: String) { +private fun CenteredMessage(paddingValues: PaddingValues, message: String, onRetry: () -> Unit) { Column( modifier = Modifier .fillMaxSize() @@ -96,6 +110,12 @@ private fun CenteredMessage(paddingValues: PaddingValues, message: String) { verticalArrangement = Arrangement.Center, ) { Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { + Icon(Icons.Filled.Refresh, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text("重试") + } } } @@ -113,10 +133,18 @@ private fun ReadyContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text(sceneName, style = MaterialTheme.typography.headlineSmall) - Spacer(Modifier.size(24.dp)) - Button(onClick = onExecute) { - Text(stringResource(R.string.scene_execution_run)) + Text(sceneName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text("点击下方按钮立即开始执行", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(24.dp)) + Button( + onClick = onExecute, + modifier = Modifier.fillMaxWidth(0.6f).height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Filled.PlayArrow, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.scene_execution_run), fontWeight = FontWeight.SemiBold) } } } @@ -131,11 +159,12 @@ private fun RunningContent(paddingValues: PaddingValues, sceneName: String) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - CircularProgressIndicator() - Spacer(Modifier.size(16.dp)) + CircularProgressIndicator(modifier = Modifier.size(56.dp)) + Spacer(Modifier.height(20.dp)) Text( stringResource(R.string.scene_execution_running, sceneName), - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, ) } } @@ -143,8 +172,9 @@ private fun RunningContent(paddingValues: PaddingValues, sceneName: String) { @Composable private fun CompletedContent( paddingValues: PaddingValues, - result: com.flagship.abox.manager.domain.SceneExecutionResult, + result: SceneExecutionResult, onBack: () -> Unit, + onRerun: () -> Unit, ) { LazyColumn( modifier = Modifier @@ -153,57 +183,131 @@ private fun CompletedContent( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - item { - Text( - text = stringResource( - R.string.scene_execution_summary, - result.succeededCount, - result.failedCount, - result.totalActions, + item(key = "summary") { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = if (result.allSucceeded) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) + }, ), - style = MaterialTheme.typography.titleMedium, - ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.scene_execution_done), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource( + R.string.scene_execution_summary, + result.succeededCount, + result.failedCount, + result.totalActions, + ), + style = MaterialTheme.typography.bodyMedium, + ) + if (result.totalDurationMillis > 0) { + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.scene_execution_duration, result.totalDurationMillis), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } - items(result.results, key = { it.action.id.value }) { entry -> - ActionResultRow(entry) + + itemsIndexed(result.results, key = { index, _ -> "action_res_$index" }) { index, entry -> + ActionResultRow(index + 1, entry) } - item { - Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(R.string.scene_execution_done)) + + item(key = "buttons") { + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onRerun, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("重新执行") + } + Button( + onClick = onBack, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Text("返回", fontWeight = FontWeight.SemiBold) + } } } } } @Composable -private fun ActionResultRow(entry: SceneActionExecutionResult) { - Card(modifier = Modifier.fillMaxWidth()) { +private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { Row( modifier = Modifier .fillMaxWidth() - .padding(12.dp), + .padding(14.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) { - Icons.Filled.Check - } else { - Icons.Filled.Close + Surface( + shape = CircleShape, + color = when (entry.outcome) { + SceneActionOutcome.SUCCESS -> MaterialTheme.colorScheme.primaryContainer + SceneActionOutcome.FAILURE -> MaterialTheme.colorScheme.errorContainer + SceneActionOutcome.SKIPPED -> MaterialTheme.colorScheme.surfaceVariant }, - contentDescription = null, - tint = if (entry.outcome == SceneActionOutcome.SUCCESS) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - }, - ) - Spacer(Modifier.size(12.dp)) + modifier = Modifier.size(32.dp), + ) { + Box(contentAlignment = Alignment.Center) { + when (entry.outcome) { + SceneActionOutcome.SUCCESS -> Icon( + Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + SceneActionOutcome.FAILURE -> Icon( + Icons.Filled.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), + ) + SceneActionOutcome.SKIPPED -> Icon( + Icons.Outlined.HourglassDisabled, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + } + Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = entry.action.describe(), + text = "步骤 $stepNumber: ${entry.action.describe()}", style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, ) entry.error?.detail?.let { detail -> + Spacer(Modifier.height(2.dp)) Text( text = detail, style = MaterialTheme.typography.bodySmall, @@ -211,11 +315,22 @@ private fun ActionResultRow(entry: SceneActionExecutionResult) { ) } } + if (entry.executionDurationMillis > 0) { + Text( + text = "${entry.executionDurationMillis}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } private fun SceneAction.describe(): String = when (this) { - is SceneAction.SocketPower -> "Socket ${deviceName.value} -> ${powerState.name}" - is SceneAction.InfraredKey -> "Infrared ${deviceName.value} -> $key" + 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" } + 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 e59c082..d23f7af 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 @@ -1,5 +1,6 @@ package com.flagship.abox.manager.ui.scene +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -10,60 +11,119 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.AutomationType import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneIcon import com.flagship.abox.manager.domain.SceneId +import kotlinx.coroutines.flow.collectLatest @OptIn(ExperimentalMaterial3Api::class) @Composable fun SceneListScreen( - onAddScene: () -> Unit, + onAddManual: () -> Unit, + onAddAutomatic: () -> Unit, onEditScene: (SceneId) -> Unit, onExecuteScene: (SceneId) -> Unit, headerContent: @Composable (() -> Unit)? = null, viewModel: SceneListViewModel = hiltViewModel(), ) { val scenes by viewModel.scenes.collectAsStateWithLifecycle() + val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle() val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle() + var showFabMenu by remember { mutableStateOf(false) } + + val context = LocalContext.current + LaunchedEffect(Unit) { + viewModel.userMessage.collectLatest { msg -> + if (msg == "executed") { + Toast.makeText(context, context.getString(R.string.scene_toast_executed), Toast.LENGTH_SHORT).show() + } + } + } Scaffold( floatingActionButton = { - ExtendedFloatingActionButton( - onClick = onAddScene, - icon = { Icon(Icons.Filled.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.scene_create_action)) }, - ) + Box { + ExtendedFloatingActionButton( + onClick = { showFabMenu = true }, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + text = { Text(stringResource(R.string.scene_create_action)) }, + ) + DropdownMenu( + expanded = showFabMenu, + onDismissRequest = { showFabMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.scene_create_manual)) }, + leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) }, + onClick = { + showFabMenu = false + onAddManual() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.scene_create_automatic)) }, + leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) }, + onClick = { + showFabMenu = false + onAddAutomatic() + }, + ) + } + } }, ) { innerPadding -> LazyColumn( @@ -71,7 +131,7 @@ fun SceneListScreen( contentPadding = PaddingValues( start = 16.dp, end = 16.dp, - top = 16.dp, + top = 12.dp, bottom = innerPadding.calculateBottomPadding() + 80.dp, ), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -81,15 +141,48 @@ fun SceneListScreen( headerContent() } } + + item(key = "tabs") { + SecondaryTabRow( + selectedTabIndex = selectedTab.ordinal, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)), + ) { + SceneFilterTab.entries.forEach { tab -> + Tab( + selected = selectedTab == tab, + onClick = { viewModel.selectTab(tab) }, + text = { + Text( + text = when (tab) { + SceneFilterTab.ALL -> stringResource(R.string.scene_tab_all) + SceneFilterTab.MANUAL -> stringResource(R.string.scene_tab_manual) + SceneFilterTab.AUTOMATIC -> stringResource(R.string.scene_tab_automatic) + }, + fontWeight = if (selectedTab == tab) FontWeight.Bold else FontWeight.Normal, + ) + }, + ) + } + } + } + if (scenes.isEmpty()) { item(key = "empty") { EmptySceneList(modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp)) } } else { items(scenes, key = { it.id.value }) { scene -> - SceneRow( + SceneCard( scene = scene, - onClick = { onExecuteScene(scene.id) }, + onClick = { + if (scene.type == AutomationType.MANUAL) { + onExecuteScene(scene.id) + } else { + onEditScene(scene.id) + } + }, + onQuickExecute = { viewModel.executeQuick(scene) }, + onToggleEnabled = { viewModel.toggleEnabled(scene) }, onEdit = { onEditScene(scene.id) }, onDelete = { viewModel.markPendingDelete(scene.id) }, ) @@ -105,7 +198,7 @@ fun SceneListScreen( text = { Text(stringResource(R.string.scene_delete_dialog_message)) }, confirmButton = { TextButton(onClick = { viewModel.deleteScene(sceneId) }) { - Text(stringResource(R.string.scene_delete_confirm)) + Text(stringResource(R.string.scene_delete_confirm), color = MaterialTheme.colorScheme.error) } }, dismissButton = { @@ -118,58 +211,169 @@ fun SceneListScreen( } @Composable -private fun SceneRow( +private fun SceneCard( scene: Scene, onClick: () -> Unit, + onQuickExecute: () -> Unit, + onToggleEnabled: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, ) { - Card(modifier = Modifier.fillMaxWidth()) { - Row( + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + ), + ) { + Column( modifier = Modifier .clickable(onClick = onClick) .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = Modifier - .size(40.dp) - .background( - color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary, - shape = CircleShape, - ), - ) - Spacer(Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = scene.name, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource( - R.string.scene_action_count, - scene.actions.size, - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = Modifier + .size(44.dp) + .background( + color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = scene.icon.displayEmoji(), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = scene.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + TypeBadge(type = scene.type) + } + if (scene.description.isNotBlank()) { + Spacer(Modifier.height(2.dp)) + Text( + text = scene.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (scene.type == AutomationType.AUTOMATIC) { + Switch( + checked = scene.enabled, + onCheckedChange = { onToggleEnabled() }, + ) + } } - IconButton(onClick = onEdit) { - Icon( - Icons.Filled.Edit, - contentDescription = stringResource(R.string.scene_edit), - ) - } - IconButton(onClick = onDelete) { - Icon( - Icons.Filled.Delete, - contentDescription = stringResource(R.string.scene_delete), - ) + + Spacer(Modifier.height(10.dp)) + + // Trigger & Action summary pills + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (scene.type == AutomationType.AUTOMATIC && scene.triggers.isNotEmpty()) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Text( + text = "⚡ ${scene.triggers.size} 触发器", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = stringResource(R.string.scene_action_count, scene.actions.size), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + if (scene.type == AutomationType.MANUAL) { + FilledTonalButton( + onClick = onQuickExecute, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + modifier = Modifier.height(32.dp), + ) { + Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium) + } + Spacer(Modifier.width(4.dp)) + } + IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) { + Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.scene_edit), modifier = Modifier.size(18.dp)) + } + IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.scene_delete), modifier = Modifier.size(18.dp)) + } + } } } } } +@Composable +private fun TypeBadge(type: AutomationType) { + val (label, bg, fg) = when (type) { + AutomationType.MANUAL -> Triple( + stringResource(R.string.scene_tab_manual), + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.onPrimaryContainer, + ) + AutomationType.AUTOMATIC -> Triple( + stringResource(R.string.scene_tab_automatic), + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Surface( + shape = RoundedCornerShape(4.dp), + color = bg, + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + style = MaterialTheme.typography.labelSmall, + color = fg, + ) + } +} + @Composable private fun EmptySceneList(modifier: Modifier = Modifier) { Column( @@ -179,12 +383,18 @@ private fun EmptySceneList(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { + Text( + text = "⚡", + style = MaterialTheme.typography.displayMedium, + ) + Spacer(Modifier.size(8.dp)) Text( text = stringResource(R.string.scene_empty_title), style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, ) - Spacer(Modifier.size(12.dp)) + Spacer(Modifier.size(8.dp)) Text( text = stringResource(R.string.scene_empty_message), style = MaterialTheme.typography.bodyMedium, @@ -194,8 +404,24 @@ private fun EmptySceneList(modifier: Modifier = Modifier) { } } -internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? { +fun SceneIcon.displayEmoji(): String = when (this) { + SceneIcon.HOME -> "🏠" + SceneIcon.OFFICE -> "💼" + SceneIcon.NIGHT -> "🌙" + SceneIcon.MOVIE -> "🎬" + SceneIcon.AWAY -> "🚶" + SceneIcon.READING -> "📖" + SceneIcon.DINING -> "🍽️" + SceneIcon.GAMING -> "🎮" + SceneIcon.THERMOSTAT -> "🌡️" + SceneIcon.LIGHT -> "💡" + SceneIcon.CLEAN -> "🧹" + SceneIcon.CUSTOM -> "✨" +} + +internal fun SceneColor.toColorOrNull(): Color? { val cleanHex = hex.removePrefix("#") return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull() } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt index bd62a57..22f2e39 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt @@ -2,32 +2,48 @@ package com.flagship.abox.manager.ui.scene import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.AutomationType import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneExecutor import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +enum class SceneFilterTab { + ALL, + MANUAL, + AUTOMATIC, +} + @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class SceneListViewModel @Inject constructor( private val sceneRepository: SceneRepository, + private val sceneExecutor: SceneExecutor, private val profileStore: ProviderProfileStore, ) : ViewModel() { - val scenes: StateFlow> = profileStore.observeActiveProfile() + private val _selectedTab = MutableStateFlow(SceneFilterTab.ALL) + val selectedTab: StateFlow = _selectedTab.asStateFlow() + + private val allScenes: StateFlow> = profileStore.observeActiveProfile() .flatMapLatest { profile -> if (profile == null) { flowOf(DomainResult.Success(emptyList())) @@ -42,9 +58,43 @@ class SceneListViewModel @Inject constructor( initialValue = emptyList(), ) + val scenes: StateFlow> = combine(allScenes, _selectedTab) { list, tab -> + when (tab) { + SceneFilterTab.ALL -> list + SceneFilterTab.MANUAL -> list.filter { it.type == AutomationType.MANUAL } + SceneFilterTab.AUTOMATIC -> list.filter { it.type == AutomationType.AUTOMATIC } + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + private val _pendingDelete = MutableStateFlow(null) val pendingDelete: StateFlow = _pendingDelete.asStateFlow() + private val _userMessage = MutableSharedFlow() + val userMessage: SharedFlow = _userMessage.asSharedFlow() + + fun selectTab(tab: SceneFilterTab) { + _selectedTab.value = tab + } + + fun toggleEnabled(scene: Scene) { + viewModelScope.launch { + val profile = profileStore.activeProfile() ?: return@launch + val updated = scene.copy(enabled = !scene.enabled) + sceneRepository.updateScene(profile.id, updated) + } + } + + fun executeQuick(scene: Scene) { + viewModelScope.launch { + sceneExecutor.execute(scene) + _userMessage.emit("executed") + } + } + fun deleteScene(sceneId: SceneId) { viewModelScope.launch { val profile = profileStore.activeProfile() ?: return@launch @@ -62,3 +112,4 @@ class SceneListViewModel @Inject constructor( } } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt index 41d8177..a94bfaf 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt @@ -9,15 +9,17 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.flagship.abox.manager.domain.AutomationType import com.flagship.abox.manager.domain.SceneId /** - * Scene navigation routes. [SceneRoutes.LIST] is the entry point; the editor and execution routes - * carry the scene id as a path segment so deep linking works without manual argument parsing. + * Scene navigation routes. */ object SceneRoutes { const val LIST = "scene/list" const val EDITOR = "scene/editor" + const val EDITOR_MANUAL = "scene/editor/manual" + const val EDITOR_AUTOMATIC = "scene/editor/automatic" const val EDITOR_WITH_ID = "scene/editor/{sceneId}" const val EXECUTION = "scene/execution/{sceneId}" @@ -39,7 +41,8 @@ fun SceneNavHost( modifier = modifier, ) { sceneListDestination( - onAddScene = { navController.navigate(SceneRoutes.EDITOR) }, + onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) }, + onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) }, onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) }, onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, ) @@ -56,14 +59,16 @@ fun SceneNavHost( } fun NavGraphBuilder.sceneListDestination( - onAddScene: () -> Unit, + onAddManual: () -> Unit, + onAddAutomatic: () -> Unit, onEditScene: (SceneId) -> Unit, onExecuteScene: (SceneId) -> Unit, headerContent: @Composable (() -> Unit)? = null, ) { composable(SceneRoutes.LIST) { SceneListScreen( - onAddScene = onAddScene, + onAddManual = onAddManual, + onAddAutomatic = onAddAutomatic, onEditScene = onEditScene, onExecuteScene = onExecuteScene, headerContent = headerContent, @@ -75,10 +80,14 @@ fun NavGraphBuilder.sceneEditorDestination( onBack: () -> Unit, onSaved: () -> Unit, ) { - composable( - route = SceneRoutes.EDITOR, - ) { - SceneEditorScreen(sceneId = null, onBack = onBack, onSaved = onSaved) + composable(SceneRoutes.EDITOR) { + SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved) + } + composable(SceneRoutes.EDITOR_MANUAL) { + SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved) + } + composable(SceneRoutes.EDITOR_AUTOMATIC) { + SceneEditorScreen(sceneId = null, initialType = AutomationType.AUTOMATIC, onBack = onBack, onSaved = onSaved) } composable( route = SceneRoutes.EDITOR_WITH_ID, @@ -87,6 +96,7 @@ fun NavGraphBuilder.sceneEditorDestination( val id = backStackEntry.arguments?.getString("sceneId") SceneEditorScreen( sceneId = id?.let { SceneId(it) }, + initialType = null, onBack = onBack, onSaved = onSaved, ) 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 8e3cf9e..7da15d5 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -42,44 +42,124 @@ 活动记录 未记录任何事件。 - - 情景模式 - 新建情景 + + 自动化与情景 + 全部 + 一键指令 + 自动指令 + 建立一键指令 + 建立自动指令 + 新建指令 %1$d 个动作 - 编辑情景 - 删除情景 - 还没有情景 - 新建第一个情景,组合插座和红外动作。 + 编辑指令 + 删除指令 + 还没有自动化指令 + 点击右下角按钮,建立一键指令或带触发器的自动指令。 + 立即执行 + 指令已触发执行 - 删除情景 - 此操作无法撤销。 + 删除指令 + 确定要删除该指令吗?此操作无法撤销。 删除 取消 - 新建情景 - 编辑情景 + 建立一键指令 + 建立自动指令 + 编辑指令 返回 - 名称 - 图标 - 主题色(十六进制) - 动作(%1$d) - 添加插座动作 - 添加红外动作 - 设备名称 - 红外按键 + 标题 + 请输入指令标题(如:回家一键开灯、高温自动降温) + 描述 + 可填写该指令的详细说明或备注 + + + 触发器 + 条件(可选) + 动作 + 进阶选项 + 其他选项与外观 + + + + 新增触发器 + + 新增条件 + + 新增动作 添加 - 保存情景 - 插座 %1$s -> %2$s - 红外 %1$s -> %2$s + 保存指令 + + + 重复执行策略 + 继续执行新任务 + 多指令同步策略 + 同步 (串行执行) + 异步 (并发执行) + 遇到错误时 + 继续执行后续动作 + 立即中止剩余动作 + 图示 + 图示背景色 + 启用该自动指令 + 开启指令执行记录 + + + 选择触发器类型 + ⏰ 定时时间触发 + 🌡️ 环境温度触发 + 💧 环境湿度触发 + 🔌 插座状态变更触发 + 触发时间点(如:07:30) + 温度阈值 (°C) + 湿度阈值 (%) + 比较条件 + 高于 (>) + 低于 (<) + 等于 (=) + + + 选择限制条件 + 🌡️ 环境温度范围 + 💧 环境湿度范围 + 🔌 插座当前电源状态 + 🕒 生效时间区间 + 起始时间(如:22:00) + 结束时间(如:07:00) + + + 选择执行动作 + 🔌 智能插座控制 + 📺 红外遥控按键 + ⏱️ 延时等待 (缓冲) + 🔔 发送系统提示 + 选择目标插座 + 设定电源状态 + 选择虚拟遥控器 + 选择遥控器功能按键 + 等待时长 (秒) + 提示标题 + 提示消息内容 + %1$d 秒 + + + 插座【%1$s】: 设为 %2$s + 遥控【%1$s】: 发送 %2$s + 红外【%1$s】: 槽位 %2$s + ⏱️ 等待 %1$d 秒 + 🔔 提示: %1$s - 执行情景 + 执行指令 执行 - 正在执行 %1$s… + 正在执行:%1$s… %3$d 个动作中成功 %1$d 个,失败 %2$d 个 - 完成 + 执行完成 + 总耗时:%1$s ms + 排队中 + 正在执行… + 正在延时等待 (%1$d s)… + 已完成 + 执行失败 + 已跳过 设备列表 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c93100b..9ee38d7 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -42,44 +42,124 @@ Activity Log No recorded events yet. - - Scenes - New scene + + Automation & Scenes + All + Manual + Automatic + New Manual Command + New Automatic Rule + New Command %1$d actions - Edit scene - Delete scene - No scenes yet - Create your first scene to combine socket and infrared actions. + Edit Command + Delete Command + No automation commands yet + Tap the button below to create instant commands or trigger-based automatic rules. + Run Now + Command triggered - Delete scene - This action cannot be undone. + Delete Command + Are you sure you want to delete this command? This cannot be undone. Delete Cancel - New scene - Edit scene + Create Manual Command + Create Automatic Rule + Edit Command Back - Name - Icon - Accent color (hex) - Actions (%1$d) - Add socket action - Add infrared action - Device name - Infrared key + Title + Enter title (e.g. Movie Night, High Temp Auto Cooling) + Description + Optional notes or details about this rule + + + Triggers + Conditions (Optional) + Actions + Advanced Options + Other Options & Appearance + + + + Add Trigger + + Add Condition + + Add Action Add - Save scene - Socket %1$s -> %2$s - Infrared %1$s -> %2$s + Save Command + + + Repeat Execution Strategy + Continue new task + Concurrency Mode + Synchronous (Sequential) + Asynchronous (Parallel) + On Error Policy + Continue subsequent actions + Abort remaining actions + Icon + Icon Background Color + Enable this automatic rule + Enable command execution logs + + + Select Trigger Type + ⏰ Time / Schedule Trigger + 🌡️ Temperature Threshold + 💧 Humidity Threshold + 🔌 Socket State Changed + Trigger Time (e.g. 07:30) + Temperature Threshold (°C) + Humidity Threshold (%) + Condition + Greater than (>) + Less than (<) + Equals (=) + + + Select Condition + 🌡️ Temperature Range + 💧 Humidity Range + 🔌 Current Socket Power + 🕒 Active Time Range + Start Time (e.g. 22:00) + End Time (e.g. 07:00) + + + Select Action + 🔌 Smart Socket Control + 📺 Infrared Remote Button + ⏱️ Delay / Buffer Timer + 🔔 System Notification + Select Target Socket + Target Power State + Select Virtual Remote + Select Remote Key + Delay Duration (seconds) + Notification Title + Notification Message + %1$d sec + + + Socket [%1$s]: Set %2$s + Remote [%1$s]: Send %2$s + Infrared [%1$s]: Slot %2$s + ⏱️ Delay %1$d seconds + 🔔 Notify: %1$s - Run scene + Execute Command Run - Running %1$s… + Running: %1$s… %1$d succeeded, %2$d failed of %3$d actions - Done + Execution Completed + Total Elapsed: %1$s ms + Pending + Running… + Waiting (%1$d s)… + Completed + Failed + Skipped Device List 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 6e90358..cd02189 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 @@ -1,6 +1,10 @@ package com.flagship.abox.manager.ui.scene +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.ExecutionStrategy import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderType @@ -8,12 +12,15 @@ import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneAction import com.flagship.abox.manager.domain.SceneActionId import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneCondition 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.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.FakeVirtualRemoteRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -31,6 +38,7 @@ class SceneEditorViewModelTest { private val profileStore = FakeProviderProfileStore() private val sceneRepository = FakeSceneRepository() + private val virtualRemoteRepository = FakeVirtualRemoteRepository() private val profileId = ProviderProfileId("local-experience") private val existingScene = Scene( @@ -58,8 +66,8 @@ class SceneEditorViewModelTest { @Test fun initializeWithNullPreparesNewScene() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, profileStore) - viewModel.initialize(null) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + viewModel.initialize(null, AutomationType.MANUAL) val state = viewModel.state.value assertFalse(state.isEditing) @@ -71,7 +79,7 @@ class SceneEditorViewModelTest { @Test fun initializeWithExistingSceneLoadsState() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) viewModel.initialize(existingScene.id) advanceUntilIdle() @@ -85,34 +93,60 @@ class SceneEditorViewModelTest { @Test fun addingAndReorderingActionsUpdatesState() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, profileStore) - viewModel.initialize(null) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + viewModel.initialize(null, AutomationType.MANUAL) viewModel.updateName("My New Scene") viewModel.addSocketAction("socket-1", SocketPowerState.ON) - viewModel.addInfraredAction("ir-1", "power") + viewModel.addDelayAction(3) + viewModel.addNotificationAction("Done", "Completed") advanceUntilIdle() - assertEquals(2, viewModel.state.value.actions.size) + assertEquals(3, viewModel.state.value.actions.size) assertTrue(viewModel.state.value.actions[0] is SceneAction.SocketPower) - assertTrue(viewModel.state.value.actions[1] is SceneAction.InfraredKey) + assertTrue(viewModel.state.value.actions[1] is SceneAction.Delay) + assertTrue(viewModel.state.value.actions[2] is SceneAction.Notification) // Move second to first viewModel.moveAction(1, 0) - assertTrue(viewModel.state.value.actions[0] is SceneAction.InfraredKey) + assertTrue(viewModel.state.value.actions[0] is SceneAction.Delay) assertTrue(viewModel.state.value.actions[1] is SceneAction.SocketPower) // Remove first action val firstId = viewModel.state.value.actions[0].id viewModel.removeAction(firstId) - assertEquals(1, viewModel.state.value.actions.size) + assertEquals(2, viewModel.state.value.actions.size) + } + + @Test + fun addingTriggersAndConditionsForAutomaticRule() = runTest { + setupProfile() + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + viewModel.initialize(null, AutomationType.AUTOMATIC) + viewModel.updateName("Auto Rule") + + viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0) + viewModel.addSocketPowerCondition("socket-1", SocketPowerState.OFF) + viewModel.addSocketAction("socket-1", SocketPowerState.ON) + viewModel.updateConcurrencyMode(ConcurrencyMode.PARALLEL) + viewModel.updateStrategy(ExecutionStrategy.ABORT_ON_ERROR) + advanceUntilIdle() + + val state = viewModel.state.value + assertEquals(1, state.triggers.size) + assertTrue(state.triggers[0] is SceneTrigger.Temperature) + assertEquals(1, state.conditions.size) + assertTrue(state.conditions[0] is SceneCondition.SocketPower) + assertEquals(ConcurrencyMode.PARALLEL, state.concurrencyMode) + assertEquals(ExecutionStrategy.ABORT_ON_ERROR, state.strategy) + assertTrue(state.isValid) } @Test fun saveNewScenePersistsToRepository() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, profileStore) - viewModel.initialize(null) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + viewModel.initialize(null, AutomationType.MANUAL) viewModel.updateName("Brand New Scene") viewModel.addSocketAction("socket-main", SocketPowerState.OFF) advanceUntilIdle() @@ -125,3 +159,4 @@ class SceneEditorViewModelTest { assertFalse(viewModel.state.value.isSaving) } } + diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt index 9fcf7e4..f5b0181 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt @@ -1,5 +1,6 @@ package com.flagship.abox.manager.ui.scene +import com.flagship.abox.manager.domain.AutomationType import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId @@ -12,14 +13,18 @@ import com.flagship.abox.manager.domain.SceneIcon import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SocketPowerState import com.flagship.abox.manager.testing.MainDispatcherRule +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor import com.flagship.abox.manager.testing.fakes.FakeSceneRepository +import com.flagship.abox.manager.testing.fakes.FakeSocketGateway import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule @@ -33,12 +38,16 @@ class SceneListViewModelTest { private val profileStore = FakeProviderProfileStore() private val sceneRepository = FakeSceneRepository() + private val socketGateway = FakeSocketGateway() + private val infraredGateway = FakeInfraredGateway() + private val sceneExecutor = FakeSceneExecutor(socketGateway, infraredGateway) private val profileId = ProviderProfileId("local-experience") - private val testScene = Scene( + private val manualScene = Scene( id = SceneId("local-experience:scene-1"), profileId = profileId, - name = "Test Scene", + name = "Manual Scene", + type = AutomationType.MANUAL, icon = SceneIcon.HOME, color = SceneColor("#FF8A65"), actions = listOf( @@ -50,39 +59,98 @@ class SceneListViewModelTest { ), ), ) + private val autoScene = Scene( + id = SceneId("local-experience:scene-2"), + profileId = profileId, + name = "Auto Rule", + type = AutomationType.AUTOMATIC, + enabled = true, + icon = SceneIcon.THERMOSTAT, + color = SceneColor("#26A69A"), + actions = listOf( + SceneAction.Delay( + id = SceneActionId("local-experience:action-2"), + profileId = profileId, + durationSeconds = 5, + ), + ), + ) @Test fun loadsScenesForActiveProfile() = runTest { profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) profileStore.setActiveProfile(profileId) - sceneRepository.setScenes(profileId, listOf(testScene)) + sceneRepository.setScenes(profileId, listOf(manualScene, autoScene)) - val viewModel = SceneListViewModel(sceneRepository, profileStore) + val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore) backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.scenes.collect {} } advanceUntilIdle() + assertEquals(2, viewModel.scenes.value.size) + assertEquals("Manual Scene", viewModel.scenes.value[0].name) + assertEquals("Auto Rule", viewModel.scenes.value[1].name) + } + + @Test + fun filterTabFiltersScenes() = runTest { + profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) + profileStore.setActiveProfile(profileId) + sceneRepository.setScenes(profileId, listOf(manualScene, autoScene)) + + val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.scenes.collect {} + } + advanceUntilIdle() + + viewModel.selectTab(SceneFilterTab.MANUAL) + advanceUntilIdle() assertEquals(1, viewModel.scenes.value.size) - assertEquals("Test Scene", viewModel.scenes.value.first().name) + assertEquals(AutomationType.MANUAL, viewModel.scenes.value[0].type) + + viewModel.selectTab(SceneFilterTab.AUTOMATIC) + advanceUntilIdle() + assertEquals(1, viewModel.scenes.value.size) + assertEquals(AutomationType.AUTOMATIC, viewModel.scenes.value[0].type) + } + + @Test + fun toggleEnabledUpdatesSceneInRepository() = runTest { + profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) + profileStore.setActiveProfile(profileId) + sceneRepository.setScenes(profileId, listOf(autoScene)) + + val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.scenes.collect {} + } + advanceUntilIdle() + + viewModel.toggleEnabled(autoScene) + advanceUntilIdle() + + val updated = viewModel.scenes.value.first() + assertFalse(updated.enabled) } @Test fun deleteSceneRemovesFromRepositoryAndClearsPending() = runTest { profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) profileStore.setActiveProfile(profileId) - sceneRepository.setScenes(profileId, listOf(testScene)) + sceneRepository.setScenes(profileId, listOf(manualScene)) - val viewModel = SceneListViewModel(sceneRepository, profileStore) + val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore) backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.scenes.collect {} } advanceUntilIdle() - viewModel.markPendingDelete(testScene.id) - assertEquals(testScene.id, viewModel.pendingDelete.value) + viewModel.markPendingDelete(manualScene.id) + assertEquals(manualScene.id, viewModel.pendingDelete.value) - viewModel.deleteScene(testScene.id) + viewModel.deleteScene(manualScene.id) advanceUntilIdle() assertNull(viewModel.pendingDelete.value) @@ -91,11 +159,12 @@ class SceneListViewModelTest { @Test fun cancelDeleteClearsPendingState() = runTest { - val viewModel = SceneListViewModel(sceneRepository, profileStore) - viewModel.markPendingDelete(testScene.id) - assertEquals(testScene.id, viewModel.pendingDelete.value) + val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore) + viewModel.markPendingDelete(manualScene.id) + assertEquals(manualScene.id, viewModel.pendingDelete.value) viewModel.cancelDelete() assertNull(viewModel.pendingDelete.value) } } + diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt index 4105b90..162f2eb 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt @@ -1,20 +1,21 @@ package com.flagship.abox.manager.domain +import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import kotlinx.coroutines.delay import java.util.concurrent.ConcurrentHashMap /** * Production implementation of [SceneExecutor]. * - * Executes scene actions sequentially via [SocketGateway] and [InfraredGateway]. - * Guarantees: - * 1. Actions are run sequentially in the order of [Scene.actions]. - * 2. Individual action failures do not abort remaining actions; all actions run to completion. - * 3. Results are aggregated into a [SceneExecutionResult]. - * 4. Re-entrant / concurrent execution of the same scene is rejected with [DomainError.OperationFailed]. + * Executes scene actions via [SocketGateway], [InfraredGateway], [SendVirtualRemoteAction], + * and built-in timers. */ class DefaultSceneExecutor( private val socketGateway: SocketGateway, private val infraredGateway: InfraredGateway, + private val sendVirtualRemoteAction: SendVirtualRemoteAction? = null, + private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) }, ) : SceneExecutor { private val runningScenes = ConcurrentHashMap() @@ -27,9 +28,41 @@ class DefaultSceneExecutor( ), ) } + val startTime = System.currentTimeMillis() + val results = mutableListOf() + var aborted = false + return try { - val results = scene.actions.map { action -> executeAction(scene.profileId, action) } - DomainResult.Success(SceneExecutionResult(scene = scene, results = results)) + for (action in scene.actions) { + if (aborted) { + results.add( + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SKIPPED, + error = null, + ), + ) + continue + } + + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + val enrichedResult = execResult.copy(executionDurationMillis = duration) + results.add(enrichedResult) + + if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) { + aborted = true + } + } + val totalDuration = System.currentTimeMillis() - startTime + DomainResult.Success( + SceneExecutionResult( + scene = scene, + results = results, + totalDurationMillis = totalDuration, + ), + ) } finally { runningScenes.remove(scene.id) } @@ -39,30 +72,80 @@ class DefaultSceneExecutor( profileId: ProviderProfileId, action: SceneAction, ): SceneActionExecutionResult { - val result = when (action) { - is SceneAction.SocketPower -> socketGateway.setPower( - profileId = profileId, - deviceName = action.deviceName, - powerState = action.powerState, - ) + return when (action) { + is SceneAction.SocketPower -> { + val res = socketGateway.setPower( + profileId = profileId, + deviceName = action.deviceName, + powerState = action.powerState, + ) + toExecutionResult(action, res) + } - is SceneAction.InfraredKey -> infraredGateway.sendKey( - profileId = profileId, - deviceName = action.deviceName, - key = action.key, - ) + is SceneAction.InfraredKey -> { + val res = infraredGateway.sendKey( + profileId = profileId, + deviceName = action.deviceName, + key = action.key, + ) + toExecutionResult(action, res) + } + + is SceneAction.VirtualRemoteButton -> { + if (sendVirtualRemoteAction != null) { + val res = sendVirtualRemoteAction.sendButton( + profileId = profileId, + remoteId = VirtualRemoteId(action.remoteId), + buttonId = action.buttonId, + ) + toExecutionResult(action, res) + } else { + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + } + } + + is SceneAction.Delay -> { + try { + delayDurationProvider(action.durationSeconds * 1000L) + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + } catch (e: Exception) { + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.FAILURE, + error = DomainError.OperationFailed(detail = e.message), + ) + } + } + + is SceneAction.Notification -> { + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + } } - return when (result) { + } + + private fun toExecutionResult(action: SceneAction, res: DomainResult<*>): SceneActionExecutionResult { + return when (res) { is DomainResult.Success -> SceneActionExecutionResult( action = action, outcome = SceneActionOutcome.SUCCESS, error = null, ) - is DomainResult.Failure -> SceneActionExecutionResult( action = action, outcome = SceneActionOutcome.FAILURE, - error = result.error, + error = res.error, ) } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt index 2df7cca..4112ef7 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt @@ -96,36 +96,63 @@ class InMemorySceneRepository : SceneRepository { Scene( id = SceneId("${profileId.value}:scene-leaving-home"), profileId = profileId, - name = "Leaving home", + name = "离家模式", + description = "关闭插座并延时关闭电视", + type = AutomationType.MANUAL, + enabled = true, icon = SceneIcon.AWAY, color = SceneColor("#FF8A65"), actions = listOf( SceneAction.SocketPower( id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"), profileId = profileId, - deviceName = DeviceName("socket-1"), + deviceName = DeviceName("Zigbee插座控制器"), powerState = SocketPowerState.OFF, ), + SceneAction.Delay( + id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"), + profileId = profileId, + durationSeconds = 1, + ), SceneAction.InfraredKey( id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"), profileId = profileId, - deviceName = DeviceName("ir-1"), - key = "power", + deviceName = DeviceName("红外控制器"), + key = "1", ), ), ), Scene( - id = SceneId("${profileId.value}:scene-movie-night"), + id = SceneId("${profileId.value}:scene-cooling-auto"), profileId = profileId, - name = "Movie night", - icon = SceneIcon.MOVIE, - color = SceneColor("#7986CB"), + name = "高温自动降温", + description = "当室温高于 28°C 时开启插座", + type = AutomationType.AUTOMATIC, + enabled = true, + icon = SceneIcon.HOME, + color = SceneColor("#26A69A"), + triggers = listOf( + SceneTrigger.Temperature( + id = SceneTriggerId("${profileId.value}:trigger-temp"), + deviceName = DeviceName("温湿度传感器"), + operator = ComparisonOperator.GREATER_THAN, + thresholdCelsius = 28.0, + label = "温度高于 28°C", + ), + ), + conditions = listOf( + SceneCondition.SocketPower( + id = SceneConditionId("${profileId.value}:cond-socket"), + deviceName = DeviceName("Zigbee插座控制器"), + expectedState = SocketPowerState.OFF, + ), + ), actions = listOf( SceneAction.SocketPower( - id = SceneActionId("${profileId.value}:scene-movie-night:socket-1"), + id = SceneActionId("${profileId.value}:scene-cooling:socket-1"), profileId = profileId, - deviceName = DeviceName("socket-1"), - powerState = SocketPowerState.OFF, + deviceName = DeviceName("Zigbee插座控制器"), + powerState = SocketPowerState.ON, ), ), ), diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt index c0ac4eb..aa97602 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt @@ -6,51 +6,164 @@ value class SceneId(val value: String) @JvmInline value class SceneActionId(val value: String) +@JvmInline +value class SceneTriggerId(val value: String) + +@JvmInline +value class SceneConditionId(val value: String) + +enum class AutomationType { + MANUAL, // 一键指令 (One-Tap / Instant Command) + AUTOMATIC, // 自动指令 (Trigger & Condition Based Automation) +} + enum class SceneActionType { SOCKET_POWER, + VIRTUAL_REMOTE_BUTTON, INFRARED_KEY, + DELAY, + NOTIFICATION, +} + +enum class ComparisonOperator(val symbol: String) { + GREATER_THAN(">"), + LESS_THAN("<"), + EQUALS("="), + GREATER_OR_EQUAL(">="), + LESS_OR_EQUAL("<="), +} + +sealed interface SceneTrigger { + val id: SceneTriggerId + + data class Time( + override val id: SceneTriggerId, + val timeOfDay: String, // e.g. "08:30" + val daysOfWeek: Set = emptySet(), // 1..7 (Monday..Sunday), empty means everyday + val label: String = "", + ) : SceneTrigger + + data class Temperature( + override val id: SceneTriggerId, + val deviceName: DeviceName, + val operator: ComparisonOperator, + val thresholdCelsius: Double, + val label: String = "", + ) : SceneTrigger + + data class Humidity( + override val id: SceneTriggerId, + val deviceName: DeviceName, + val operator: ComparisonOperator, + val thresholdPercent: Double, + val label: String = "", + ) : SceneTrigger + + data class DeviceState( + override val id: SceneTriggerId, + val deviceName: DeviceName, + val expectedState: String, // e.g. "ON" / "OFF" + val label: String = "", + ) : SceneTrigger +} + +sealed interface SceneCondition { + val id: SceneConditionId + + data class Temperature( + override val id: SceneConditionId, + val deviceName: DeviceName, + val operator: ComparisonOperator, + val thresholdCelsius: Double, + ) : SceneCondition + + data class Humidity( + override val id: SceneConditionId, + val deviceName: DeviceName, + val operator: ComparisonOperator, + val thresholdPercent: Double, + ) : SceneCondition + + data class SocketPower( + override val id: SceneConditionId, + val deviceName: DeviceName, + val expectedState: SocketPowerState, + ) : SceneCondition + + data class TimeRange( + override val id: SceneConditionId, + val startTime: String, // "22:00" + val endTime: String, // "07:00" + ) : SceneCondition } /** * A single executable step inside a [Scene]. - * - * Socket actions reference the [DeviceName] accepted by ABSDK 0630 and a target [SocketPowerState]. - * Infrared actions reference a [DeviceName] and the stable key string provided by the infrared - * module; the executor delegates the actual send to [InfraredGateway.sendKey]. - * - * `DeviceName` is used (rather than `DeviceId`) because ABSDK addresses devices by name, matching - * the existing [SocketGateway] and [InfraredGateway] contracts. The infrared module is responsible - * for keeping the key string stable across edits, so scenes can reference it without coupling to - * the infrared storage primary key. */ sealed interface SceneAction { val id: SceneActionId val profileId: ProviderProfileId - val deviceName: DeviceName val type: SceneActionType data class SocketPower( override val id: SceneActionId, override val profileId: ProviderProfileId, - override val deviceName: DeviceName, + val deviceName: DeviceName, val powerState: SocketPowerState, ) : SceneAction { override val type: SceneActionType get() = SceneActionType.SOCKET_POWER } + data class VirtualRemoteButton( + override val id: SceneActionId, + override val profileId: ProviderProfileId, + val remoteId: String, + val remoteDisplayName: String, + val buttonId: String, + val buttonDisplayName: String, + ) : SceneAction { + override val type: SceneActionType get() = SceneActionType.VIRTUAL_REMOTE_BUTTON + } + data class InfraredKey( override val id: SceneActionId, override val profileId: ProviderProfileId, - override val deviceName: DeviceName, + val deviceName: DeviceName, val key: String, ) : SceneAction { override val type: SceneActionType get() = SceneActionType.INFRARED_KEY } + + data class Delay( + override val id: SceneActionId, + override val profileId: ProviderProfileId, + val durationSeconds: Int, + ) : SceneAction { + override val type: SceneActionType get() = SceneActionType.DELAY + } + + data class Notification( + override val id: SceneActionId, + override val profileId: ProviderProfileId, + val title: String, + val message: String, + ) : SceneAction { + override val type: SceneActionType get() = SceneActionType.NOTIFICATION + } +} + +enum class ExecutionStrategy { + CONTINUE_ON_ERROR, // 遇错继续 + ABORT_ON_ERROR, // 遇错中止 +} + +enum class ConcurrencyMode { + SEQUENTIAL, // 同步 / 串行 + PARALLEL, // 异步 / 并行 } /** - * Visual identity for a scene. Kept as a small enum so the UI can resolve the icon without - * external resources and so the persistence layer stays stable across icon set changes. + * Visual identity for a scene. */ enum class SceneIcon { HOME, @@ -58,12 +171,17 @@ enum class SceneIcon { NIGHT, MOVIE, AWAY, + READING, + DINING, + GAMING, + THERMOSTAT, + LIGHT, + CLEAN, CUSTOM, } /** - * Scene accent color stored as an uppercase hex string (e.g. `#FF8A65`). The UI maps it to the - * Compose color space; persistence stores it verbatim. + * Scene accent color stored as an uppercase hex string (e.g. `#3F51B5`). */ @JvmInline value class SceneColor(val hex: String) @@ -72,7 +190,15 @@ data class Scene( val id: SceneId, val profileId: ProviderProfileId, val name: String, - val icon: SceneIcon, - val color: SceneColor, - val actions: List, + val description: String = "", + val type: AutomationType = AutomationType.MANUAL, + val enabled: Boolean = true, + val icon: SceneIcon = SceneIcon.HOME, + val color: SceneColor = SceneColor("#3F51B5"), + val triggers: List = emptyList(), + val conditions: List = emptyList(), + val actions: List = emptyList(), + val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR, + val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL, + val enableLogging: Boolean = true, ) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt index 8637c78..f6267ed 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt @@ -3,6 +3,7 @@ package com.flagship.abox.manager.domain enum class SceneActionOutcome { SUCCESS, FAILURE, + SKIPPED, } /** @@ -12,21 +13,24 @@ data class SceneActionExecutionResult( val action: SceneAction, val outcome: SceneActionOutcome, val error: DomainError?, + val executionDurationMillis: Long = 0L, ) /** - * Aggregated result of executing a [Scene]. Actions are executed sequentially in the order they - * appear in [scene.actions]; a per-action failure does not abort subsequent actions. + * Aggregated result of executing a [Scene]. */ data class SceneExecutionResult( val scene: Scene, val results: List, + val totalDurationMillis: Long = 0L, ) { val totalActions: Int get() = results.size val succeededCount: Int get() = results.count { it.outcome == SceneActionOutcome.SUCCESS } val failedCount: Int get() = results.count { it.outcome == SceneActionOutcome.FAILURE } + val skippedCount: Int + get() = results.count { it.outcome == SceneActionOutcome.SKIPPED } val allSucceeded: Boolean - get() = results.isNotEmpty() && failedCount == 0 + get() = results.isNotEmpty() && failedCount == 0 && skippedCount == 0 } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt index 907060c..90173e0 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt @@ -149,4 +149,77 @@ class DefaultSceneExecutorTest { assertEquals(1, socketGateway.calls.size) assertEquals(1, infraredGateway.calls.size) } + + @Test + fun abortsExecutionOnFailureWhenStrategyIsAbortOnError() = runTest { + val socketGateway = TestSocketGateway( + response = DomainResult.Failure(DomainError.DeviceOffline("socket-1 unreachable")), + ) + val infraredGateway = TestInfraredGateway( + response = DomainResult.Success(Unit), + ) + val executor = DefaultSceneExecutor(socketGateway, infraredGateway) + + val action1 = SceneAction.SocketPower( + id = SceneActionId("a1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.OFF, + ) + val action2 = SceneAction.InfraredKey( + id = SceneActionId("a2"), + profileId = profileId, + deviceName = DeviceName("ir-1"), + key = "vol_up", + ) + val scene = Scene( + id = SceneId("s1"), + profileId = profileId, + name = "Test Scene", + icon = SceneIcon.MOVIE, + color = SceneColor("#7986CB"), + actions = listOf(action1, action2), + strategy = ExecutionStrategy.ABORT_ON_ERROR, + ) + + val result = executor.execute(scene) + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + + assertEquals(2, executionResult.results.size) + assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome) + assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome) + assertEquals(0, infraredGateway.calls.size) + } + + @Test + fun executesDelayActionSuccessfully() = runTest { + var delayCalled = 0L + val executor = DefaultSceneExecutor( + socketGateway = TestSocketGateway(), + infraredGateway = TestInfraredGateway(), + delayDurationProvider = { delayCalled = it }, + ) + + val action = SceneAction.Delay( + id = SceneActionId("d1"), + profileId = profileId, + durationSeconds = 5, + ) + val scene = Scene( + id = SceneId("s1"), + profileId = profileId, + name = "Delay Scene", + actions = listOf(action), + ) + + val result = executor.execute(scene) + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + + assertEquals(1, executionResult.results.size) + assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome) + assertEquals(5000L, delayCalled) + } } + 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 7d47222..28e620b 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 @@ -181,12 +181,6 @@ class FakeSceneRepository : SceneRepository { /** * Fake [SceneExecutor] that drives the existing [SocketGateway] and [InfraredGateway] fakes. - * - * Behaviour matches the project_plan.md §4.7 contract: - * - actions run sequentially in [Scene.actions] order; - * - a single action failure does not abort subsequent actions; - * - the returned [SceneExecutionResult] contains one entry per action; - * - re-entering execute() while a scene is running returns [DomainError.OperationFailed]. */ class FakeSceneExecutor( private val socketGateway: SocketGateway, @@ -203,9 +197,23 @@ class FakeSceneExecutor( ), ) } + val startTime = System.currentTimeMillis() + val results = mutableListOf() return try { - val results = scene.actions.map { action -> executeAction(scene.profileId, action) } - DomainResult.Success(SceneExecutionResult(scene = scene, results = results)) + for (action in scene.actions) { + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + results.add(execResult.copy(executionDurationMillis = duration)) + } + val totalDuration = System.currentTimeMillis() - startTime + DomainResult.Success( + SceneExecutionResult( + scene = scene, + results = results, + totalDurationMillis = totalDuration, + ), + ) } finally { running.remove(scene.id) } @@ -227,6 +235,10 @@ class FakeSceneExecutor( deviceName = action.deviceName, key = action.key, ) + + is SceneAction.VirtualRemoteButton, + is SceneAction.Delay, + is SceneAction.Notification -> DomainResult.Success(Unit) } return when (result) { is DomainResult.Success -> SceneActionExecutionResult(