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 555ad99..314c4d7 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,15 +1,11 @@ package com.flagship.abox.manager.ui.scene -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke 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 @@ -24,18 +20,24 @@ 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.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.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.HourglassDisabled +import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.Schedule -import androidx.compose.material.icons.outlined.Thermostat +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.AlertDialog 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.FilterChip import androidx.compose.material3.Icon @@ -74,7 +76,6 @@ 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 @@ -82,13 +83,10 @@ 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", -) +private const val DEFAULT_SOCKET_DEVICE_NAME = "Zigbee插座控制器" +private const val DEFAULT_TH_DEVICE_NAME = "温湿度传感器" +private const val DEFAULT_IR_DEVICE_NAME = "红外控制器" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -99,24 +97,36 @@ fun SceneEditorScreen( onSaved: () -> Unit, viewModel: SceneEditorViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsStateWithLifecycle() - - 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) { + LaunchedEffect(sceneId, initialType) { viewModel.initialize(sceneId, initialType) } + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(state.isSaved) { if (state.isSaved) { onSaved() } } + SceneEditorContent( + state = state, + viewModel = viewModel, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SceneEditorContent( + state: SceneEditorState, + viewModel: SceneEditorViewModel, + onBack: () -> Unit, +) { + var showTriggerDialog by remember { mutableStateOf(false) } + var showConditionDialog by remember { mutableStateOf(false) } + var showActionDialog by remember { mutableStateOf(false) } + Scaffold( topBar = { TopAppBar( @@ -127,69 +137,229 @@ fun SceneEditorScreen( else if (state.type == AutomationType.AUTOMATIC) R.string.scene_editor_auto_title else R.string.scene_editor_manual_title, ), - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, ) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.scene_back)) + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } }, actions = { IconButton( onClick = viewModel::save, - enabled = state.isValid && !state.isSaving, + enabled = state.isValid && !state.isSaving && !state.isLoading, ) { 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), + tint = if (state.isValid && !state.isSaving && !state.isLoading) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), ) } }, ) }, ) { innerPadding -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - item(key = "title_card") { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - 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), - ) - } + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + stringResource(R.string.scene_loading_scene), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item(key = "title_card") { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + 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), + ) + } + } + } - if (state.type == AutomationType.AUTOMATIC) { - item(key = "triggers_card") { + item(key = "icon_color_card") { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(R.string.scene_section_other), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + listOf( + SceneIcon.HOME to "Home", + SceneIcon.NIGHT to "Night", + SceneIcon.AWAY to "Away", + SceneIcon.MOVIE to "Movie", + SceneIcon.CUSTOM to "Custom", + ).forEach { (icon, name) -> + val isSelected = state.icon == icon + Surface( + shape = RoundedCornerShape(12.dp), + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier + .size(52.dp) + .clickable { viewModel.updateIcon(icon) }, + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = when (icon) { + SceneIcon.HOME -> Icons.Outlined.CheckCircle + SceneIcon.NIGHT -> Icons.Outlined.Schedule + SceneIcon.AWAY -> Icons.Outlined.HourglassDisabled + SceneIcon.MOVIE -> Icons.Outlined.PlayCircle + else -> Icons.Outlined.Settings + }, + contentDescription = name, + tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(26.dp), + ) + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + listOf( + "#3F51B5", "#E91E63", "#26A69A", "#FF8A65", "#673AB7", "#4CAF50", + ).forEach { hex -> + val isSelected = state.colorHex.equals(hex, ignoreCase = true) + val parsedColor = try { + Color(android.graphics.Color.parseColor(hex)) + } catch (e: Exception) { + MaterialTheme.colorScheme.primary + } + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(parsedColor) + .clickable { viewModel.updateColor(hex) }, + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + Icons.Filled.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } + } + } + + if (state.type == AutomationType.AUTOMATIC) { + item(key = "triggers_card") { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + 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, + ) + } + Text( + "${state.triggers.size}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (state.triggers.isEmpty()) { + Text( + stringResource(R.string.scene_empty_triggers_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + state.triggers.forEach { trigger -> + TriggerItemRow(trigger = trigger, onRemove = { viewModel.removeTrigger(trigger.id) }) + } + } + + OutlinedButton( + onClick = { showTriggerDialog = true }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.scene_add_trigger)) + } + } + } + } + } + + item(key = "conditions_card") { OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), @@ -203,208 +373,138 @@ fun SceneEditorScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Outlined.Schedule, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Icon(Icons.Outlined.Tune, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) Spacer(Modifier.width(8.dp)) Text( - stringResource(R.string.scene_section_triggers), + stringResource(R.string.scene_section_conditions), 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, - ) - } - } - - if (state.triggers.isEmpty()) { Text( - text = stringResource(R.string.scene_empty_triggers_hint), - style = MaterialTheme.typography.bodySmall, + "${state.conditions.size}", + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - state.triggers.forEach { trigger -> - TriggerItemRow(trigger = trigger, onRemove = { viewModel.removeTrigger(trigger.id) }) + if (state.conditions.isEmpty()) { + Text( + stringResource(R.string.scene_empty_conditions_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + state.conditions.forEach { condition -> + ConditionItemRow(condition = condition, onRemove = { viewModel.removeCondition(condition.id) }) + } } OutlinedButton( - onClick = { showTriggerDialog = true }, + onClick = { showConditionDialog = true }, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), ) { - Text(stringResource(R.string.scene_add_trigger)) + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.scene_add_condition)) } } } } - } - item(key = "conditions_card") { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - 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, - ) - } - } - - if (state.conditions.isEmpty()) { + item(key = "actions_card_header") { + 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(22.dp)) + Spacer(Modifier.width(8.dp)) Text( - text = stringResource(R.string.scene_empty_conditions_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + stringResource(R.string.scene_section_actions), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, ) } - - 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)) - } + Text( + stringResource(R.string.scene_action_count, state.actions.size), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - } - item(key = "actions_header") { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row( + if (state.actions.isEmpty()) { + item(key = "actions_empty") { + OutlinedCard( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { - 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, + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, ) { 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, + stringResource(R.string.scene_empty_actions_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - - if (state.actions.isEmpty()) { - Text( - text = stringResource(R.string.scene_empty_actions_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + } + } else { + itemsIndexed( + items = state.actions, + key = { _, action -> action.id.value }, + ) { index, action -> + ActionItemRow( + stepNumber = index + 1, + action = action, + canMoveUp = index > 0, + canMoveDown = index < state.actions.size - 1, + onMoveUp = { viewModel.moveAction(index, index - 1) }, + onMoveDown = { viewModel.moveAction(index, index + 1) }, + onRemove = { viewModel.removeAction(action.id) }, + ) } } - } - itemsIndexed(state.actions, key = { _, it -> it.id.value }) { index, action -> - ActionCardRow( - action = 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(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 = "add_action_button") { + OutlinedButton( + onClick = { showActionDialog = true }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.scene_add_action_btn), fontWeight = FontWeight.SemiBold) + } } - } - item(key = "advanced_options") { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { expandAdvanced = !expandAdvanced }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + item(key = "execution_strategy_card") { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - "★ " + stringResource(R.string.scene_section_advanced), + 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) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.scene_concurrency_mode), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( selected = state.concurrencyMode == ConcurrencyMode.SEQUENTIAL, @@ -417,8 +517,10 @@ fun SceneEditorScreen( label = { Text(stringResource(R.string.scene_concurrency_async)) }, ) } + } - Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.labelMedium) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( selected = state.strategy == ExecutionStrategy.CONTINUE_ON_ERROR, @@ -432,115 +534,50 @@ fun SceneEditorScreen( ) } } - } - } - } - } - item(key = "other_options") { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - 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), + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Text(stringResource(R.string.scene_appearance_icon), style = MaterialTheme.typography.labelMedium) - IconPickerRow( - selectedIcon = state.icon, - onSelect = viewModel::updateIcon, + Text(stringResource(R.string.scene_enable_logging), style = MaterialTheme.typography.bodyMedium) + Switch( + checked = state.enableLogging, + onCheckedChange = viewModel::updateLogging, ) - - 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() - .height(48.dp), - shape = RoundedCornerShape(12.dp), - ) { - Text(stringResource(R.string.scene_save_action), fontWeight = FontWeight.Bold) + 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 && !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Text(stringResource(R.string.scene_save_action), fontWeight = FontWeight.Bold) + } } } } } - val defaultSensorName = stringResource(R.string.device_temperature_humidity_title) - val defaultSocketName = stringResource(R.string.scene_default_socket_name) - if (showTriggerDialog) { AddTriggerDialog( onDismiss = { showTriggerDialog = false }, - onAdd = { triggerType, time, temp, hum, op -> + onAdd = { triggerType, devName, time, temp, hum, op -> when (triggerType) { 0 -> viewModel.addTimeTrigger(time) - 1 -> viewModel.addTemperatureTrigger(defaultSensorName, op, temp) - 2 -> viewModel.addHumidityTrigger(defaultSensorName, op, hum) + 1 -> viewModel.addTemperatureTrigger(devName, op, temp) + 2 -> viewModel.addHumidityTrigger(devName, op, hum) } showTriggerDialog = false }, @@ -550,16 +587,16 @@ fun SceneEditorScreen( if (showConditionDialog) { AddConditionDialog( onDismiss = { showConditionDialog = false }, - onAddTemp = { op, temp -> - viewModel.addTemperatureCondition(defaultSensorName, op, temp) + onAddTemp = { devName, op, temp -> + viewModel.addTemperatureCondition(devName, op, temp) showConditionDialog = false }, - onAddHumidity = { op, hum -> - viewModel.addHumidityCondition(defaultSensorName, op, hum) + onAddHumidity = { devName, op, hum -> + viewModel.addHumidityCondition(devName, op, hum) showConditionDialog = false }, - onAddSocket = { powerState -> - viewModel.addSocketPowerCondition(defaultSocketName, powerState) + onAddSocket = { devName, powerState -> + viewModel.addSocketPowerCondition(devName, powerState) showConditionDialog = false }, onAddTimeRange = { start, end -> @@ -599,26 +636,18 @@ private fun TriggerItemRow(trigger: SceneTrigger, onRemove: () -> Unit) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surface, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), ) { Row( - modifier = Modifier.padding(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - val (icon, desc) = when (trigger) { - is SceneTrigger.Time -> Pair("⏰", stringResource(R.string.scene_trigger_type_time) + ": ${trigger.timeOfDay}") - is SceneTrigger.Temperature -> Pair("🌡️", stringResource(R.string.scene_trigger_desc_temp, trigger.operator.symbol, trigger.thresholdCelsius.toInt())) - is SceneTrigger.Humidity -> Pair("💧", stringResource(R.string.scene_trigger_desc_humidity, trigger.operator.symbol, trigger.thresholdPercent.toInt())) - is SceneTrigger.DeviceState -> Pair("🔌", stringResource(R.string.scene_trigger_desc_device, trigger.deviceName.value, trigger.expectedState)) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text(icon, style = MaterialTheme.typography.titleMedium) - 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)) + Text(trigger.describe(), style = MaterialTheme.typography.bodyMedium) + IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.scene_delete_confirm), modifier = Modifier.size(16.dp)) } } } @@ -629,43 +658,36 @@ private fun ConditionItemRow(condition: SceneCondition, onRemove: () -> Unit) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surface, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), ) { Row( - modifier = Modifier.padding(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - val (icon, desc) = when (condition) { - is SceneCondition.Temperature -> Pair("🌡️", stringResource(R.string.scene_condition_desc_temp, condition.operator.symbol, condition.thresholdCelsius.toInt())) - is SceneCondition.Humidity -> Pair("💧", stringResource(R.string.scene_condition_desc_humidity, condition.operator.symbol, condition.thresholdPercent.toInt())) - is SceneCondition.SocketPower -> Pair("🔌", stringResource(R.string.scene_condition_desc_socket, condition.deviceName.value, condition.expectedState.name)) - is SceneCondition.TimeRange -> Pair("🕒", stringResource(R.string.scene_condition_desc_time, condition.startTime, condition.endTime)) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text(icon, style = MaterialTheme.typography.titleMedium) - 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)) + Text(condition.describe(), style = MaterialTheme.typography.bodyMedium) + IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.scene_delete_confirm), modifier = Modifier.size(16.dp)) } } } } @Composable -private fun ActionCardRow( +private fun ActionItemRow( + stepNumber: Int, action: SceneAction, - index: Int, - total: Int, - onMoveUp: (Int) -> Unit, - onMoveDown: (Int) -> Unit, + canMoveUp: Boolean, + canMoveDown: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, onRemove: () -> Unit, ) { OutlinedCard( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(14.dp), colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { @@ -681,87 +703,78 @@ private fun ActionCardRow( modifier = Modifier.size(28.dp), ) { Box(contentAlignment = Alignment.Center) { - Text("${index + 1}", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + Text( + text = stepNumber.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) } } - Spacer(Modifier.width(10.dp)) + Spacer(Modifier.width(12.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 = action.describe(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + if (canMoveUp) { + IconButton(onClick = onMoveUp, modifier = Modifier.size(32.dp)) { + Icon(Icons.Filled.KeyboardArrowUp, contentDescription = null, modifier = Modifier.size(18.dp)) + } } - 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, modifier = Modifier.size(32.dp)) { - Text("↓", style = MaterialTheme.typography.titleMedium) - } - 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 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) + if (canMoveDown) { + IconButton(onClick = onMoveDown, modifier = Modifier.size(32.dp)) { + Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(18.dp)) + } + } + IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.scene_delete_confirm), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), + ) } } } } } -@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, - ), - ) - } - } +private fun SceneTrigger.describe(): String = when (this) { + is SceneTrigger.Time -> "⏰ $timeOfDay" + is SceneTrigger.Temperature -> stringResource(R.string.scene_trigger_desc_temp, operator.symbol, thresholdCelsius.toInt().toString()) + is SceneTrigger.Humidity -> stringResource(R.string.scene_trigger_desc_humidity, operator.symbol, thresholdPercent.toInt().toString()) + is SceneTrigger.DeviceState -> stringResource(R.string.scene_trigger_desc_device, deviceName.value, expectedState) +} + +@Composable +private fun SceneCondition.describe(): String = when (this) { + is SceneCondition.Temperature -> stringResource(R.string.scene_condition_desc_temp, operator.symbol, thresholdCelsius.toInt().toString()) + is SceneCondition.Humidity -> stringResource(R.string.scene_condition_desc_humidity, operator.symbol, thresholdPercent.toInt().toString()) + is SceneCondition.SocketPower -> stringResource(R.string.scene_condition_desc_socket, deviceName.value, expectedState.name) + is SceneCondition.TimeRange -> stringResource(R.string.scene_condition_desc_time, startTime, endTime) +} + +@Composable +private fun SceneAction.describe(): String = when (this) { + is SceneAction.SocketPower -> stringResource(R.string.scene_action_socket, deviceName.value, powerState.name) + is SceneAction.VirtualRemoteButton -> stringResource(R.string.scene_action_remote, remoteDisplayName, buttonDisplayName) + is SceneAction.InfraredKey -> stringResource(R.string.scene_action_infrared, deviceName.value, key) + is SceneAction.Delay -> stringResource(R.string.scene_action_delay, durationSeconds) + is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title) } @Composable private fun AddTriggerDialog( onDismiss: () -> Unit, - onAdd: (type: Int, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit, + onAdd: (type: Int, deviceName: String, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit, ) { var selectedType by remember { mutableIntStateOf(0) } + var deviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) } var timeInput by remember { mutableStateOf("07:30") } var tempThreshold by remember { mutableDoubleStateOf(28.0) } var humThreshold by remember { mutableDoubleStateOf(40.0) } @@ -769,6 +782,7 @@ private fun AddTriggerDialog( val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") } val isTimeValid = timeRegex.matches(timeInput.trim()) + val isDeviceValid = selectedType == 0 || deviceName.isNotBlank() AlertDialog( onDismissRequest = onDismiss, @@ -795,6 +809,13 @@ private fun AddTriggerDialog( ) } 1, 2 -> { + OutlinedTextField( + value = deviceName, + onValueChange = { deviceName = it }, + label = { Text(stringResource(R.string.scene_device_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) }) FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) @@ -811,9 +832,9 @@ private fun AddTriggerDialog( } }, confirmButton = { - val canConfirm = selectedType != 0 || isTimeValid + val canConfirm = (selectedType != 0 || isTimeValid) && isDeviceValid Button( - onClick = { onAdd(selectedType, timeInput.trim(), tempThreshold, humThreshold, operator) }, + onClick = { onAdd(selectedType, deviceName.trim(), timeInput.trim(), tempThreshold, humThreshold, operator) }, enabled = canConfirm, ) { Text(stringResource(R.string.scene_add_action)) @@ -828,12 +849,15 @@ private fun AddTriggerDialog( @Composable private fun AddConditionDialog( onDismiss: () -> Unit, - onAddTemp: (ComparisonOperator, Double) -> Unit, - onAddHumidity: (ComparisonOperator, Double) -> Unit, - onAddSocket: (SocketPowerState) -> Unit, + onAddTemp: (deviceName: String, ComparisonOperator, Double) -> Unit, + onAddHumidity: (deviceName: String, ComparisonOperator, Double) -> Unit, + onAddSocket: (deviceName: String, SocketPowerState) -> Unit, onAddTimeRange: (String, String) -> Unit, ) { var selectedType by remember { mutableIntStateOf(0) } + var tempDeviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) } + var humDeviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) } + var socketDeviceName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) } var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) } var tempVal by remember { mutableDoubleStateOf(26.0) } var humVal by remember { mutableDoubleStateOf(50.0) } @@ -846,6 +870,13 @@ private fun AddConditionDialog( val isEndTimeValid = timeRegex.matches(endTime.trim()) val isTimeRangeValid = isStartTimeValid && isEndTimeValid + val isDeviceValid = when (selectedType) { + 0 -> tempDeviceName.isNotBlank() + 1 -> humDeviceName.isNotBlank() + 2 -> socketDeviceName.isNotBlank() + else -> true + } + AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) }, @@ -859,6 +890,13 @@ private fun AddConditionDialog( } when (selectedType) { 0 -> { + OutlinedTextField( + value = tempDeviceName, + onValueChange = { tempDeviceName = it }, + label = { Text(stringResource(R.string.scene_device_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) }) FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) @@ -867,6 +905,13 @@ private fun AddConditionDialog( Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f) } 1 -> { + OutlinedTextField( + value = humDeviceName, + onValueChange = { humDeviceName = it }, + label = { Text(stringResource(R.string.scene_device_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) }) FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) @@ -875,6 +920,13 @@ private fun AddConditionDialog( Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f) } 2 -> { + OutlinedTextField( + value = socketDeviceName, + onValueChange = { socketDeviceName = it }, + label = { Text(stringResource(R.string.scene_device_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) Text(stringResource(R.string.scene_target_socket_state)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) }) @@ -909,13 +961,13 @@ private fun AddConditionDialog( } }, confirmButton = { - val canConfirm = selectedType != 3 || isTimeRangeValid + val canConfirm = (selectedType != 3 || isTimeRangeValid) && isDeviceValid Button( onClick = { when (selectedType) { - 0 -> onAddTemp(operator, tempVal) - 1 -> onAddHumidity(operator, humVal) - 2 -> onAddSocket(socketPower) + 0 -> onAddTemp(tempDeviceName.trim(), operator, tempVal) + 1 -> onAddHumidity(humDeviceName.trim(), operator, humVal) + 2 -> onAddSocket(socketDeviceName.trim(), socketPower) 3 -> onAddTimeRange(startTime.trim(), endTime.trim()) } }, @@ -941,13 +993,11 @@ private fun AddActionDialog( onAddNotify: (title: String, msg: String) -> Unit, ) { var actionType by remember { mutableIntStateOf(0) } - val defaultSocketName = stringResource(R.string.scene_default_socket_name) - val defaultIrName = stringResource(R.string.scene_default_ir_name) val defaultPowerBtn = stringResource(R.string.scene_default_button_power) val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title) val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg) - var socketName by remember { mutableStateOf(defaultSocketName) } + var socketName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) } var socketPower by remember { mutableStateOf(SocketPowerState.ON) } var selectedRemoteIndex by remember { mutableIntStateOf(0) } @@ -1055,21 +1105,30 @@ private fun AddActionDialog( } }, 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", defaultIrName, selectedButtonId, selectedButtonName) + val canConfirm = when (actionType) { + 0 -> socketName.isNotBlank() + 1 -> selectedButtonId.isNotBlank() + 3 -> notifyTitle.isNotBlank() + else -> true + } + Button( + onClick = { + when (actionType) { + 0 -> onAddSocket(socketName.trim(), socketPower) + 1 -> { + val remote = availableRemotes.getOrNull(selectedRemoteIndex) + if (remote != null) { + onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName) + } else { + onAddRemote("default-ir", DEFAULT_IR_DEVICE_NAME, selectedButtonId, selectedButtonName) + } } + 2 -> onAddDelay(delaySeconds) + 3 -> onAddNotify(notifyTitle.trim(), notifyMsg.trim()) } - 2 -> onAddDelay(delaySeconds) - 3 -> onAddNotify(notifyTitle, notifyMsg) - } - }) { + }, + enabled = canConfirm, + ) { Text(stringResource(R.string.scene_add_action)) } }, 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 17d5dcf..e38d4ee 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 @@ -50,11 +50,12 @@ data class SceneEditorState( val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL, val enableLogging: Boolean = true, val availableRemotes: List = emptyList(), + val isLoading: Boolean = false, val isSaving: Boolean = false, val isSaved: Boolean = false, val errorMessage: String? = null, ) { - val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty()) + val isValid: Boolean get() = !isLoading && name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty()) val isEditing: Boolean get() = sceneId != null } @@ -100,14 +101,24 @@ class SceneEditorViewModel @Inject constructor( type = initialType ?: AutomationType.MANUAL, colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5", availableRemotes = it.availableRemotes, + isLoading = false, ) } return } + + _state.update { + it.copy( + sceneId = sceneId, + isLoading = true, + errorMessage = null, + ) + } + viewModelScope.launch { val profile = profileStore.activeProfile() if (profile == null) { - _state.update { it.copy(errorMessage = "No active profile") } + _state.update { it.copy(isLoading = false, errorMessage = "No active profile") } return@launch } val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value @@ -127,10 +138,11 @@ class SceneEditorViewModel @Inject constructor( strategy = scene.strategy, concurrencyMode = scene.concurrencyMode, enableLogging = scene.enableLogging, + isLoading = false, ) } } else { - _state.update { it.copy(errorMessage = "Scene not found") } + _state.update { it.copy(isLoading = false, errorMessage = "Scene not found") } } } } 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 257617c..89d8729 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -196,8 +196,9 @@ 湿度需 %1$s %2$s%% %1$s 处于 %2$s 时间在 %1$s ~ %2$s 之间 - Zigbee插座控制器 - 红外控制器 + 设备名称(网关配对标识) + 正在加载情景配置… + 无法加载情景配置。 电源 情景完成 自动化任务执行完成 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ec69aa7..4745798 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -196,8 +196,9 @@ Humidity must be %1$s %2$s%% %1$s is %2$s Time is between %1$s ~ %2$s - Zigbee Smart Socket - Infrared Controller + Device Name (Gateway Identifier) + Loading scene... + Failed to load scene. Power Scene Completed Automation task executed successfully 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 863c6fa..3458c11 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 @@ -81,10 +81,18 @@ class SceneEditorViewModelTest { setupProfile() val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) viewModel.initialize(existingScene.id) + + // Immediately binds sceneId and enters loading state + assertTrue(viewModel.state.value.isEditing) + assertEquals(existingScene.id, viewModel.state.value.sceneId) + assertTrue(viewModel.state.value.isLoading) + assertFalse(viewModel.state.value.isValid) + advanceUntilIdle() val state = viewModel.state.value assertTrue(state.isEditing) + assertFalse(state.isLoading) assertEquals("Existing Scene", state.name) assertEquals(1, state.actions.size) assertTrue(state.isValid) 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 127a5bd..d0111c9 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 @@ -4,6 +4,7 @@ import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -36,15 +37,48 @@ class DefaultSceneExecutor( return try { val results: List = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) { - coroutineScope { - scene.actions.map { action -> - async { - val actionStart = System.currentTimeMillis() - val execResult = executeAction(scene.profileId, action) - val duration = System.currentTimeMillis() - actionStart - execResult.copy(executionDurationMillis = duration) + if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) { + coroutineScope { + lateinit var deferreds: List> + deferreds = scene.actions.mapIndexed { index, action -> + async { + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + val enriched = execResult.copy(executionDurationMillis = duration) + if (enriched.outcome == SceneActionOutcome.FAILURE) { + deferreds.forEachIndexed { otherIndex, otherDeferred -> + if (otherIndex != index) { + otherDeferred.cancel() + } + } + } + enriched + } } - }.awaitAll() + deferreds.mapIndexed { index, deferred -> + try { + deferred.await() + } catch (e: CancellationException) { + SceneActionExecutionResult( + action = scene.actions[index], + outcome = SceneActionOutcome.SKIPPED, + error = null, + ) + } + } + } + } else { + coroutineScope { + scene.actions.map { action -> + async { + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + execResult.copy(executionDurationMillis = duration) + } + }.awaitAll() + } } } else { val sequentialResults = mutableListOf() 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 ae7a092..0dd2f3a 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 @@ -308,5 +308,45 @@ class DefaultSceneExecutorTest { ) executor.execute(scene) } + + @Test + fun parallelAbortOnErrorCancelsPendingActions() = runTest { + val socketGateway = TestSocketGateway( + response = DomainResult.Failure(DomainError.OperationFailed(detail = "Socket failed")), + ) + val executor = DefaultSceneExecutor( + socketGateway = socketGateway, + infraredGateway = TestInfraredGateway(), + delayDurationProvider = { + kotlinx.coroutines.delay(1000) + }, + ) + val action1 = SceneAction.SocketPower( + id = SceneActionId("a1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ) + val action2 = SceneAction.Delay( + id = SceneActionId("a2"), + profileId = profileId, + durationSeconds = 10, + ) + val scene = Scene( + id = SceneId("s-par-abort"), + profileId = profileId, + name = "Parallel Abort Scene", + concurrencyMode = ConcurrencyMode.PARALLEL, + strategy = ExecutionStrategy.ABORT_ON_ERROR, + actions = listOf(action1, action2), + ) + + 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) + } } 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 81c5bf8..ab94b5a 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 @@ -22,6 +22,7 @@ import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.SocketPowerState import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -227,15 +228,48 @@ class FakeSceneExecutor( return try { val results: List = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) { - coroutineScope { - scene.actions.map { action -> - async { - val actionStart = System.currentTimeMillis() - val execResult = executeAction(scene.profileId, action) - val duration = System.currentTimeMillis() - actionStart - execResult.copy(executionDurationMillis = duration) + if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) { + coroutineScope { + lateinit var deferreds: List> + deferreds = scene.actions.mapIndexed { index, action -> + async { + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + val enriched = execResult.copy(executionDurationMillis = duration) + if (enriched.outcome == SceneActionOutcome.FAILURE) { + deferreds.forEachIndexed { otherIndex, otherDeferred -> + if (otherIndex != index) { + otherDeferred.cancel() + } + } + } + enriched + } } - }.awaitAll() + deferreds.mapIndexed { index, deferred -> + try { + deferred.await() + } catch (e: CancellationException) { + SceneActionExecutionResult( + action = scene.actions[index], + outcome = SceneActionOutcome.SKIPPED, + error = null, + ) + } + } + } + } else { + coroutineScope { + scene.actions.map { action -> + async { + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + execResult.copy(executionDurationMillis = duration) + } + }.awaitAll() + } } } else { val sequentialResults = mutableListOf() diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt index b2cb7d5..4f83b35 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt @@ -1,8 +1,10 @@ package com.flagship.abox.manager.testing.fakes +import com.flagship.abox.manager.domain.ConcurrencyMode import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ExecutionStrategy import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneAction @@ -156,6 +158,44 @@ class FakeSceneExecutorTest { assertEquals(listOf(notificationAction), executor.sentNotifications) } + @Test + fun parallelAbortOnErrorCancelsPendingActions() = runTest { + val socketGateway = FakeSocketGateway() + socketGateway.setError( + profileId, + DeviceName("socket-1"), + DomainError.DeviceOffline(detail = "offline"), + ) + val executor = FakeSceneExecutor( + socketGateway = socketGateway, + infraredGateway = FakeInfraredGateway(), + delayDurationProvider = { kotlinx.coroutines.delay(1000) }, + ) + + val action1 = socketAction("socket-1", SocketPowerState.ON) + val action2 = SceneAction.Delay( + id = SceneActionId("abox:delay-1"), + profileId = profileId, + durationSeconds = 10, + ) + + val scene = Scene( + id = SceneId("${profileId.value}:scene-par-abort"), + profileId = profileId, + name = "Parallel Abort Scene", + concurrencyMode = ConcurrencyMode.PARALLEL, + strategy = ExecutionStrategy.ABORT_ON_ERROR, + actions = listOf(action1, action2), + ) + + 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) + } + private fun scene(actions: List): Scene = Scene( id = SceneId("${profileId.value}:scene-test"), profileId = profileId,