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

Merged
KremeCN merged 10 commits from feat/scene-editor into main 2026-08-25 20:20:19 +08:00
18 changed files with 2345 additions and 410 deletions
Showing only changes of commit 9ac1616046 - Show all commits
@@ -6,6 +6,7 @@ import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.SceneExecutor import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneRepository import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -27,5 +28,10 @@ object SceneIntegrationModule {
fun provideSceneExecutor( fun provideSceneExecutor(
socketGateway: SocketGateway, socketGateway: SocketGateway,
infraredGateway: InfraredGateway, infraredGateway: InfraredGateway,
): SceneExecutor = DefaultSceneExecutor(socketGateway, infraredGateway) sendVirtualRemoteAction: SendVirtualRemoteAction,
): SceneExecutor = DefaultSceneExecutor(
socketGateway = socketGateway,
infraredGateway = infraredGateway,
sendVirtualRemoteAction = sendVirtualRemoteAction,
)
} }
@@ -130,7 +130,8 @@ private fun AuthenticatedNavHost(
}, },
scenesSlot = { scenesSlot = {
SceneListScreen( 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)) }, onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
) )
File diff suppressed because it is too large Load Diff
@@ -2,32 +2,54 @@ package com.flagship.abox.manager.ui.scene
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.DeviceName
import com.flagship.abox.manager.domain.DomainResult 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.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor 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.SceneIcon
import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository 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.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 dagger.hilt.android.lifecycle.HiltViewModel
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class SceneEditorState( data class SceneEditorState(
val sceneId: SceneId? = null, val sceneId: SceneId? = null,
val type: AutomationType = AutomationType.MANUAL,
val name: String = "", val name: String = "",
val description: String = "",
val enabled: Boolean = true,
val icon: SceneIcon = SceneIcon.HOME, val icon: SceneIcon = SceneIcon.HOME,
val colorHex: String = "#FF8A65", val colorHex: String = "#3F51B5",
val triggers: List<SceneTrigger> = emptyList(),
val conditions: List<SceneCondition> = emptyList(),
val actions: List<SceneAction> = emptyList(), val actions: List<SceneAction> = emptyList(),
val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR,
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
val enableLogging: Boolean = true,
val availableRemotes: List<VirtualRemote> = emptyList(),
val isSaving: Boolean = false, val isSaving: Boolean = false,
val isSaved: Boolean = false, val isSaved: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
@@ -39,15 +61,40 @@ data class SceneEditorState(
@HiltViewModel @HiltViewModel
class SceneEditorViewModel @Inject constructor( class SceneEditorViewModel @Inject constructor(
private val sceneRepository: SceneRepository, private val sceneRepository: SceneRepository,
private val virtualRemoteRepository: VirtualRemoteRepository,
private val profileStore: ProviderProfileStore, private val profileStore: ProviderProfileStore,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(SceneEditorState()) private val _state = MutableStateFlow(SceneEditorState())
val state: StateFlow<SceneEditorState> = _state.asStateFlow() val state: StateFlow<SceneEditorState> = _state.asStateFlow()
fun initialize(sceneId: SceneId?) { init {
Outdated
Review

状态重置

initialize 对相同参数也会无条件重置或重新加载状态。Activity 配置变更后 ViewModel 会保留,但界面的 LaunchedEffect 会再次调用此方法,导致新建场景的输入全部丢失,或编辑中的修改被仓库版本覆盖。应记录已初始化的场景 ID,或使用 SavedStateHandle 保存编辑状态,避免重复初始化。

**状态重置** `initialize` 对相同参数也会无条件重置或重新加载状态。Activity 配置变更后 ViewModel 会保留,但界面的 `LaunchedEffect` 会再次调用此方法,导致新建场景的输入全部丢失,或编辑中的修改被仓库版本覆盖。应记录已初始化的场景 ID,或使用 `SavedStateHandle` 保存编辑状态,避免重复初始化。 <!-- pr-agent-dedup: cf3b7ac56d25 --> <!-- pr-agent-key-issue-location: ce4b9c356768 -->
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) { 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 return
} }
viewModelScope.launch { viewModelScope.launch {
@@ -58,13 +105,23 @@ class SceneEditorViewModel @Inject constructor(
} }
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
if (scene != null) { if (scene != null) {
_state.value = SceneEditorState( _state.update {
it.copy(
sceneId = scene.id, sceneId = scene.id,
type = scene.type,
name = scene.name, name = scene.name,
description = scene.description,
enabled = scene.enabled,
icon = scene.icon, icon = scene.icon,
colorHex = scene.color.hex, colorHex = scene.color.hex,
triggers = scene.triggers,
conditions = scene.conditions,
actions = scene.actions, actions = scene.actions,
strategy = scene.strategy,
concurrencyMode = scene.concurrencyMode,
enableLogging = scene.enableLogging,
) )
}
} else { } else {
_state.update { it.copy(errorMessage = "Scene not found") } _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 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 updateIcon(icon: SceneIcon) = _state.update { it.copy(icon = icon) }
fun updateColor(hex: String) = _state.update { it.copy(colorHex = hex) } 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<Int> = 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) { fun addSocketAction(deviceName: String, powerState: SocketPowerState) {
Outdated
Review

设备硬编码

observeRemotes 将红外设备名固定为中文字符串 红外控制器。只要当前配置中的实际设备名不同,例如英文名称、自定义名称或默认设备 ID,仓库查询就无法加载该设备的虚拟遥控器,遥控动作选择器会错误显示为空。应从当前配置或设备列表取得真实的 DeviceName。

**设备硬编码** `observeRemotes` 将红外设备名固定为中文字符串 `红外控制器`。只要当前配置中的实际设备名不同,例如英文名称、自定义名称或默认设备 ID,仓库查询就无法加载该设备的虚拟遥控器,遥控动作选择器会错误显示为空。应从当前配置或设备列表取得真实的 `DeviceName`。 <!-- pr-agent-dedup: 2b0abea93edd --> <!-- pr-agent-key-issue-location: 10a59afea5f1 -->
if (deviceName.isBlank()) return if (deviceName.isBlank()) return
viewModelScope.launch { 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) { fun addInfraredAction(deviceName: String, key: String) {
if (deviceName.isBlank() || key.isBlank()) return if (deviceName.isBlank() || key.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
@@ -105,6 +275,33 @@ class SceneEditorViewModel @Inject constructor(
} }
} }
Outdated
Review

编辑竞态

编辑现有场景时,sceneId 只有在异步查询成功后才写入状态,且没有加载态阻止编辑或保存。仓库响应较慢或失败时,页面会按“新建”处理;用户若完成有效输入并保存,save() 将调用 createScene,产生重复场景而不是更新目标。应先绑定待编辑的 ID,并在加载完成前禁用保存。

**编辑竞态** 编辑现有场景时,`sceneId` 只有在异步查询成功后才写入状态,且没有加载态阻止编辑或保存。仓库响应较慢或失败时,页面会按“新建”处理;用户若完成有效输入并保存,`save()` 将调用 `createScene`,产生重复场景而不是更新目标。应先绑定待编辑的 ID,并在加载完成前禁用保存。 <!-- pr-agent-dedup: 41ec446dd682 --> <!-- pr-agent-key-issue-location: 2c71b6e33002 -->
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) = fun removeAction(actionId: SceneActionId) =
_state.update { it.copy(actions = it.actions.filterNot { a -> a.id == actionId }) } _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) } _state.update { it.copy(actions = updated) }
} }
suspend fun getKeyBindings(remoteId: VirtualRemoteId): List<RemoteKeyBinding> {
return when (val res = virtualRemoteRepository.getKeyBindings(remoteId)) {
is DomainResult.Success -> res.value
is DomainResult.Failure -> emptyList()
}
}
fun save() { fun save() {
val state = _state.value val state = _state.value
if (!state.isValid || state.isSaving) return if (!state.isValid || state.isSaving) return
@@ -129,9 +333,17 @@ class SceneEditorViewModel @Inject constructor(
id = state.sceneId ?: SceneId("${profile.id.value}:scene-${UUID.randomUUID()}"), id = state.sceneId ?: SceneId("${profile.id.value}:scene-${UUID.randomUUID()}"),
profileId = profile.id, profileId = profile.id,
name = state.name.trim(), name = state.name.trim(),
description = state.description.trim(),
type = state.type,
enabled = state.enabled,
icon = state.icon, icon = state.icon,
color = SceneColor(state.colorHex), color = SceneColor(state.colorHex),
triggers = state.triggers,
conditions = state.conditions,
actions = state.actions, actions = state.actions,
strategy = state.strategy,
concurrencyMode = state.concurrencyMode,
enableLogging = state.enableLogging,
) )
val result = if (state.isEditing) { val result = if (state.isEditing) {
sceneRepository.updateScene(profile.id, scene) sceneRepository.updateScene(profile.id, scene)
@@ -151,3 +363,4 @@ class SceneEditorViewModel @Inject constructor(
} }
} }
@@ -1,28 +1,39 @@
package com.flagship.abox.manager.ui.scene package com.flagship.abox.manager.ui.scene
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close 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.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -30,8 +41,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.SceneAction
import com.flagship.abox.manager.domain.SceneActionExecutionResult import com.flagship.abox.manager.domain.SceneActionExecutionResult
import com.flagship.abox.manager.domain.SceneActionOutcome import com.flagship.abox.manager.domain.SceneActionOutcome
import com.flagship.abox.manager.domain.SceneExecutionResult
import com.flagship.abox.manager.domain.SceneId
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SceneExecutionScreen( fun SceneExecutionScreen(
sceneId: com.flagship.abox.manager.domain.SceneId, sceneId: SceneId,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: SceneExecutionViewModel = hiltViewModel(), viewModel: SceneExecutionViewModel = hiltViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(sceneId) { viewModel.initialize(sceneId) } LaunchedEffect(sceneId) {
viewModel.initialize(sceneId)
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.scene_execution_title)) }, title = { Text(stringResource(R.string.scene_execution_title), fontWeight = FontWeight.SemiBold) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back))
@@ -64,29 +79,28 @@ fun SceneExecutionScreen(
) { innerPadding -> ) { innerPadding ->
when (val state = uiState) { when (val state = uiState) {
SceneExecutionUiState.Loading -> CenteredLoading(innerPadding) 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.Ready -> ReadyContent(innerPadding, state.scene.name, viewModel::execute)
is SceneExecutionUiState.Running -> RunningContent(innerPadding, state.scene.name) 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 @Composable
private fun CenteredLoading(paddingValues: PaddingValues) { private fun CenteredLoading(paddingValues: PaddingValues) {
Row( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(paddingValues),
horizontalArrangement = Arrangement.Center, contentAlignment = Alignment.Center,
verticalAlignment = Alignment.CenterVertically,
) { ) {
CircularProgressIndicator() CircularProgressIndicator()
} }
} }
@Composable @Composable
private fun CenteredMessage(paddingValues: PaddingValues, message: String) { private fun CenteredMessage(paddingValues: PaddingValues, message: String, onRetry: () -> Unit) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -96,6 +110,12 @@ private fun CenteredMessage(paddingValues: PaddingValues, message: String) {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error) 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, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text(sceneName, style = MaterialTheme.typography.headlineSmall) Text(sceneName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.size(24.dp)) Spacer(Modifier.height(8.dp))
Button(onClick = onExecute) { Text("点击下方按钮立即开始执行", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.scene_execution_run)) 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, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
CircularProgressIndicator() CircularProgressIndicator(modifier = Modifier.size(56.dp))
Spacer(Modifier.size(16.dp)) Spacer(Modifier.height(20.dp))
Text( Text(
stringResource(R.string.scene_execution_running, sceneName), 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 @Composable
private fun CompletedContent( private fun CompletedContent(
paddingValues: PaddingValues, paddingValues: PaddingValues,
result: com.flagship.abox.manager.domain.SceneExecutionResult, result: SceneExecutionResult,
onBack: () -> Unit, onBack: () -> Unit,
onRerun: () -> Unit,
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
@@ -153,7 +183,25 @@ private fun CompletedContent(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item { 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)
},
),
) {
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(
text = stringResource( text = stringResource(
R.string.scene_execution_summary, R.string.scene_execution_summary,
@@ -161,49 +209,105 @@ private fun CompletedContent(
result.failedCount, result.failedCount,
result.totalActions, result.totalActions,
), ),
style = MaterialTheme.typography.titleMedium, 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)
} }
item { }
Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { }
Text(stringResource(R.string.scene_execution_done))
itemsIndexed(result.results, key = { index, _ -> "action_res_$index" }) { index, entry ->
ActionResultRow(index + 1, entry)
}
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 @Composable
private fun ActionResultRow(entry: SceneActionExecutionResult) { private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) {
Card(modifier = Modifier.fillMaxWidth()) { Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp), .padding(14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Surface(
imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) { shape = CircleShape,
Icons.Filled.Check color = when (entry.outcome) {
} else { SceneActionOutcome.SUCCESS -> MaterialTheme.colorScheme.primaryContainer
Icons.Filled.Close SceneActionOutcome.FAILURE -> MaterialTheme.colorScheme.errorContainer
SceneActionOutcome.SKIPPED -> MaterialTheme.colorScheme.surfaceVariant
}, },
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center) {
when (entry.outcome) {
SceneActionOutcome.SUCCESS -> Icon(
Icons.Filled.Check,
contentDescription = null, contentDescription = null,
tint = if (entry.outcome == SceneActionOutcome.SUCCESS) { tint = MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.primary modifier = Modifier.size(18.dp),
} else {
MaterialTheme.colorScheme.error
},
) )
Spacer(Modifier.size(12.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)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = entry.action.describe(), text = "步骤 $stepNumber: ${entry.action.describe()}",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
) )
entry.error?.detail?.let { detail -> entry.error?.detail?.let { detail ->
Spacer(Modifier.height(2.dp))
Text( Text(
text = detail, text = detail,
style = MaterialTheme.typography.bodySmall, 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) { private fun SceneAction.describe(): String = when (this) {
is SceneAction.SocketPower -> "Socket ${deviceName.value} -> ${powerState.name}" is SceneAction.SocketPower -> "插座【${deviceName.value}】-> ${powerState.name}"
is SceneAction.InfraredKey -> "Infrared ${deviceName.value} -> $key" is SceneAction.VirtualRemoteButton -> "遥控【$remoteDisplayName】-> $buttonDisplayName"
is SceneAction.InfraredKey -> "红外【${deviceName.value}】-> 槽位 $key"
is SceneAction.Delay -> "⏱️ 等待 ${durationSeconds} 秒"
is SceneAction.Notification -> "🔔 提示: $title"
} }
@@ -1,5 +1,6 @@
package com.flagship.abox.manager.ui.scene package com.flagship.abox.manager.ui.scene
import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement 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.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit 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.AlertDialog
import androidx.compose.material3.Card 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.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R 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.Scene
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneId
import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SceneListScreen( fun SceneListScreen(
onAddScene: () -> Unit, onAddManual: () -> Unit,
onAddAutomatic: () -> Unit,
onEditScene: (SceneId) -> Unit, onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit, onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null, headerContent: @Composable (() -> Unit)? = null,
viewModel: SceneListViewModel = hiltViewModel(), viewModel: SceneListViewModel = hiltViewModel(),
) { ) {
val scenes by viewModel.scenes.collectAsStateWithLifecycle() val scenes by viewModel.scenes.collectAsStateWithLifecycle()
Outdated
Review

实验 API

此处使用了 Material 3 的 TopAppBar,但文件没有像另外两个新增页面一样声明 ExperimentalMaterial3Api opt-in。在该 API 仍为实验状态的依赖版本下,这会导致编译失败;应为对应 Composable 添加 @OptIn(ExperimentalMaterial3Api::class)。

**实验 API** 此处使用了 Material 3 的 `TopAppBar`,但文件没有像另外两个新增页面一样声明 `ExperimentalMaterial3Api` opt-in。在该 API 仍为实验状态的依赖版本下,这会导致编译失败;应为对应 Composable 添加 `@OptIn(ExperimentalMaterial3Api::class)`。 <!-- pr-agent-dedup: 08c1d62de7aa --> <!-- pr-agent-key-issue-location: 4ba0616d8fbc -->
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
val pendingDelete by viewModel.pendingDelete.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( Scaffold(
floatingActionButton = { floatingActionButton = {
Box {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = onAddScene, onClick = { showFabMenu = true },
icon = { Icon(Icons.Filled.Add, contentDescription = null) }, icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text(stringResource(R.string.scene_create_action)) }, 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 -> ) { innerPadding ->
LazyColumn( LazyColumn(
@@ -71,7 +131,7 @@ fun SceneListScreen(
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = 16.dp, start = 16.dp,
end = 16.dp, end = 16.dp,
top = 16.dp, top = 12.dp,
bottom = innerPadding.calculateBottomPadding() + 80.dp, bottom = innerPadding.calculateBottomPadding() + 80.dp,
), ),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -81,15 +141,48 @@ fun SceneListScreen(
headerContent() 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()) { if (scenes.isEmpty()) {
item(key = "empty") { item(key = "empty") {
EmptySceneList(modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp)) EmptySceneList(modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp))
} }
} else { } else {
items(scenes, key = { it.id.value }) { scene -> items(scenes, key = { it.id.value }) { scene ->
SceneRow( SceneCard(
scene = scene, 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) }, onEdit = { onEditScene(scene.id) },
onDelete = { viewModel.markPendingDelete(scene.id) }, onDelete = { viewModel.markPendingDelete(scene.id) },
) )
@@ -105,7 +198,7 @@ fun SceneListScreen(
text = { Text(stringResource(R.string.scene_delete_dialog_message)) }, text = { Text(stringResource(R.string.scene_delete_dialog_message)) },
confirmButton = { confirmButton = {
TextButton(onClick = { viewModel.deleteScene(sceneId) }) { TextButton(onClick = { viewModel.deleteScene(sceneId) }) {
Text(stringResource(R.string.scene_delete_confirm)) Text(stringResource(R.string.scene_delete_confirm), color = MaterialTheme.colorScheme.error)
} }
}, },
dismissButton = { dismissButton = {
@@ -118,55 +211,166 @@ fun SceneListScreen(
} }
@Composable @Composable
private fun SceneRow( private fun SceneCard(
scene: Scene, scene: Scene,
onClick: () -> Unit, onClick: () -> Unit,
onQuickExecute: () -> Unit,
onToggleEnabled: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
Card(modifier = Modifier.fillMaxWidth()) { Card(
Row( modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
),
) {
Column(
modifier = Modifier modifier = Modifier
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(16.dp), .padding(16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(44.dp)
.background( .background(
color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary, color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary,
shape = CircleShape, shape = RoundedCornerShape(12.dp),
), ),
contentAlignment = Alignment.Center,
) {
Text(
text = scene.icon.displayEmoji(),
style = MaterialTheme.typography.titleMedium,
) )
Spacer(Modifier.width(16.dp)) }
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text( Text(
text = scene.name, text = scene.name,
style = MaterialTheme.typography.titleMedium, 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(
text = stringResource( text = scene.description,
R.string.scene_action_count,
scene.actions.size,
),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) maxLines = 1,
} overflow = TextOverflow.Ellipsis,
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),
) )
} }
} }
if (scene.type == AutomationType.AUTOMATIC) {
Switch(
checked = scene.enabled,
onCheckedChange = { onToggleEnabled() },
)
}
}
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,
)
} }
} }
@@ -179,12 +383,18 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text(
text = "⚡",
style = MaterialTheme.typography.displayMedium,
)
Spacer(Modifier.size(8.dp))
Text( Text(
text = stringResource(R.string.scene_empty_title), text = stringResource(R.string.scene_empty_title),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold,
) )
Spacer(Modifier.size(12.dp)) Spacer(Modifier.size(8.dp))
Text( Text(
text = stringResource(R.string.scene_empty_message), text = stringResource(R.string.scene_empty_message),
style = MaterialTheme.typography.bodyMedium, 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("#") val cleanHex = hex.removePrefix("#")
return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull() return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull()
} }
@@ -2,32 +2,48 @@ package com.flagship.abox.manager.ui.scene
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene 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.SceneId
import com.flagship.abox.manager.domain.SceneRepository import com.flagship.abox.manager.domain.SceneRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
enum class SceneFilterTab {
ALL,
MANUAL,
AUTOMATIC,
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class SceneListViewModel @Inject constructor( class SceneListViewModel @Inject constructor(
private val sceneRepository: SceneRepository, private val sceneRepository: SceneRepository,
private val sceneExecutor: SceneExecutor,
private val profileStore: ProviderProfileStore, private val profileStore: ProviderProfileStore,
) : ViewModel() { ) : ViewModel() {
val scenes: StateFlow<List<Scene>> = profileStore.observeActiveProfile() private val _selectedTab = MutableStateFlow(SceneFilterTab.ALL)
val selectedTab: StateFlow<SceneFilterTab> = _selectedTab.asStateFlow()
private val allScenes: StateFlow<List<Scene>> = profileStore.observeActiveProfile()
.flatMapLatest { profile -> .flatMapLatest { profile ->
if (profile == null) { if (profile == null) {
flowOf(DomainResult.Success(emptyList())) flowOf(DomainResult.Success(emptyList()))
@@ -42,9 +58,43 @@ class SceneListViewModel @Inject constructor(
initialValue = emptyList(), initialValue = emptyList(),
) )
val scenes: StateFlow<List<Scene>> = 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<SceneId?>(null) private val _pendingDelete = MutableStateFlow<SceneId?>(null)
val pendingDelete: StateFlow<SceneId?> = _pendingDelete.asStateFlow() val pendingDelete: StateFlow<SceneId?> = _pendingDelete.asStateFlow()
private val _userMessage = MutableSharedFlow<String>()
val userMessage: SharedFlow<String> = _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) { fun deleteScene(sceneId: SceneId) {
viewModelScope.launch { viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch val profile = profileStore.activeProfile() ?: return@launch
@@ -62,3 +112,4 @@ class SceneListViewModel @Inject constructor(
} }
} }
@@ -9,15 +9,17 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneId
/** /**
* Scene navigation routes. [SceneRoutes.LIST] is the entry point; the editor and execution routes * Scene navigation routes.
* carry the scene id as a path segment so deep linking works without manual argument parsing.
*/ */
object SceneRoutes { object SceneRoutes {
const val LIST = "scene/list" const val LIST = "scene/list"
const val EDITOR = "scene/editor" 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 EDITOR_WITH_ID = "scene/editor/{sceneId}"
const val EXECUTION = "scene/execution/{sceneId}" const val EXECUTION = "scene/execution/{sceneId}"
@@ -39,7 +41,8 @@ fun SceneNavHost(
modifier = modifier, modifier = modifier,
) { ) {
sceneListDestination( 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)) }, onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
) )
@@ -56,14 +59,16 @@ fun SceneNavHost(
} }
fun NavGraphBuilder.sceneListDestination( fun NavGraphBuilder.sceneListDestination(
onAddScene: () -> Unit, onAddManual: () -> Unit,
onAddAutomatic: () -> Unit,
onEditScene: (SceneId) -> Unit, onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit, onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null, headerContent: @Composable (() -> Unit)? = null,
) { ) {
composable(SceneRoutes.LIST) { composable(SceneRoutes.LIST) {
SceneListScreen( SceneListScreen(
onAddScene = onAddScene, onAddManual = onAddManual,
onAddAutomatic = onAddAutomatic,
onEditScene = onEditScene, onEditScene = onEditScene,
onExecuteScene = onExecuteScene, onExecuteScene = onExecuteScene,
headerContent = headerContent, headerContent = headerContent,
@@ -75,10 +80,14 @@ fun NavGraphBuilder.sceneEditorDestination(
onBack: () -> Unit, onBack: () -> Unit,
onSaved: () -> Unit, onSaved: () -> Unit,
) { ) {
composable( composable(SceneRoutes.EDITOR) {
route = SceneRoutes.EDITOR, SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved)
) { }
SceneEditorScreen(sceneId = null, 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( composable(
route = SceneRoutes.EDITOR_WITH_ID, route = SceneRoutes.EDITOR_WITH_ID,
@@ -87,6 +96,7 @@ fun NavGraphBuilder.sceneEditorDestination(
val id = backStackEntry.arguments?.getString("sceneId") val id = backStackEntry.arguments?.getString("sceneId")
SceneEditorScreen( SceneEditorScreen(
sceneId = id?.let { SceneId(it) }, sceneId = id?.let { SceneId(it) },
initialType = null,
onBack = onBack, onBack = onBack,
onSaved = onSaved, onSaved = onSaved,
) )
@@ -42,44 +42,124 @@
<string name="activity_placeholder_title">活动记录</string> <string name="activity_placeholder_title">活动记录</string>
<string name="activity_placeholder_description">未记录任何事件。</string> <string name="activity_placeholder_description">未记录任何事件。</string>
<!-- Scene: list --> <!-- Scene & Automation strings -->
<string name="scene_list_title">情景模式</string> <string name="scene_list_title">自动化与情景</string>
<string name="scene_create_action">新建情景</string> <string name="scene_tab_all">全部</string>
<string name="scene_tab_manual">一键指令</string>
<string name="scene_tab_automatic">自动指令</string>
<string name="scene_create_manual">建立一键指令</string>
<string name="scene_create_automatic">建立自动指令</string>
<string name="scene_create_action">新建指令</string>
<string name="scene_action_count">%1$d 个动作</string> <string name="scene_action_count">%1$d 个动作</string>
<string name="scene_edit">编辑情景</string> <string name="scene_edit">编辑指令</string>
<string name="scene_delete">删除情景</string> <string name="scene_delete">删除指令</string>
<string name="scene_empty_title">还没有情景</string> <string name="scene_empty_title">还没有自动化指令</string>
<string name="scene_empty_message">新建第一个情景,组合插座和红外动作。</string> <string name="scene_empty_message">点击右下角按钮,建立一键指令或带触发器的自动指令。</string>
<string name="scene_action_run_now">立即执行</string>
<string name="scene_toast_executed">指令已触发执行</string>
<!-- Scene: delete dialog --> <!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">删除情景</string> <string name="scene_delete_dialog_title">删除指令</string>
<string name="scene_delete_dialog_message">此操作无法撤销。</string> <string name="scene_delete_dialog_message">确定要删除该指令吗?此操作无法撤销。</string>
<string name="scene_delete_confirm">删除</string> <string name="scene_delete_confirm">删除</string>
<string name="scene_delete_cancel">取消</string> <string name="scene_delete_cancel">取消</string>
<!-- Scene: editor --> <!-- Scene: editor -->
<string name="scene_editor_new_title">新建情景</string> <string name="scene_editor_manual_title">建立一键指令</string>
<string name="scene_editor_edit_title">编辑情景</string> <string name="scene_editor_auto_title">建立自动指令</string>
<string name="scene_editor_edit_title">编辑指令</string>
<string name="scene_back">返回</string> <string name="scene_back">返回</string>
<string name="scene_name_label">名称</string> <string name="scene_name_label">标题</string>
<string name="scene_icon_label">图标</string> <string name="scene_name_hint">请输入指令标题(如:回家一键开灯、高温自动降温)</string>
<string name="scene_color_label">主题色(十六进制)</string> <string name="scene_description_label">描述</string>
<string name="scene_actions_label">动作(%1$d)</string> <string name="scene_description_hint">可填写该指令的详细说明或备注</string>
<string name="scene_add_socket_action">添加插座动作</string>
<string name="scene_add_infrared_action">添加红外动作</string> <!-- Sections -->
<string name="scene_device_name_label">设备名称</string> <string name="scene_section_triggers">触发器</string>
<string name="scene_infrared_key_label">红外按键</string> <string name="scene_section_conditions">条件(可选)</string>
<string name="scene_section_actions">动作</string>
<string name="scene_section_advanced">进阶选项</string>
<string name="scene_section_other">其他选项与外观</string>
<!-- Add Buttons -->
<string name="scene_add_trigger">+ 新增触发器</string>
<string name="scene_add_condition">+ 新增条件</string>
<string name="scene_add_action_btn">+ 新增动作</string>
<string name="scene_add_action">添加</string> <string name="scene_add_action">添加</string>
<string name="scene_save_action">保存情景</string> <string name="scene_save_action">保存指令</string>
<string name="scene_action_socket">插座 %1$s -&gt; %2$s</string>
<string name="scene_action_infrared">红外 %1$s -&gt; %2$s</string> <!-- Strategies & Toggles -->
<string name="scene_repeat_strategy">重复执行策略</string>
<string name="scene_strategy_continue">继续执行新任务</string>
<string name="scene_concurrency_mode">多指令同步策略</string>
<string name="scene_concurrency_sync">同步 (串行执行)</string>
<string name="scene_concurrency_async">异步 (并发执行)</string>
<string name="scene_error_policy">遇到错误时</string>
<string name="scene_policy_continue">继续执行后续动作</string>
<string name="scene_policy_abort">立即中止剩余动作</string>
<string name="scene_appearance_icon">图示</string>
<string name="scene_appearance_color">图示背景色</string>
<string name="scene_enable_rule">启用该自动指令</string>
<string name="scene_enable_logging">开启指令执行记录</string>
<!-- Trigger Types & Dialog -->
<string name="scene_dialog_add_trigger_title">选择触发器类型</string>
<string name="scene_trigger_type_time">⏰ 定时时间触发</string>
<string name="scene_trigger_type_temp">🌡️ 环境温度触发</string>
<string name="scene_trigger_type_humidity">💧 环境湿度触发</string>
<string name="scene_trigger_type_device">🔌 插座状态变更触发</string>
<string name="scene_trigger_time_label">触发时间点(如:07:30)</string>
<string name="scene_trigger_temp_threshold">温度阈值 (°C)</string>
<string name="scene_trigger_humidity_threshold">湿度阈值 (%)</string>
<string name="scene_operator_label">比较条件</string>
<string name="scene_operator_gt">高于 (&gt;)</string>
<string name="scene_operator_lt">低于 (&lt;)</string>
<string name="scene_operator_eq">等于 (=)</string>
<!-- Condition Types & Dialog -->
<string name="scene_dialog_add_condition_title">选择限制条件</string>
<string name="scene_condition_type_temp">🌡️ 环境温度范围</string>
<string name="scene_condition_type_humidity">💧 环境湿度范围</string>
<string name="scene_condition_type_socket">🔌 插座当前电源状态</string>
<string name="scene_condition_type_time_range">🕒 生效时间区间</string>
<string name="scene_condition_start_time">起始时间(如:22:00)</string>
<string name="scene_condition_end_time">结束时间(如:07:00)</string>
<!-- Action Types & Dialog -->
<string name="scene_dialog_add_action_title">选择执行动作</string>
<string name="scene_action_type_socket">🔌 智能插座控制</string>
<string name="scene_action_type_virtual_remote">📺 红外遥控按键</string>
<string name="scene_action_type_delay">⏱️ 延时等待 (缓冲)</string>
<string name="scene_action_type_notification">🔔 发送系统提示</string>
<string name="scene_select_socket">选择目标插座</string>
<string name="scene_socket_power_state">设定电源状态</string>
<string name="scene_select_virtual_remote">选择虚拟遥控器</string>
<string name="scene_select_remote_button">选择遥控器功能按键</string>
<string name="scene_delay_duration_label">等待时长 (秒)</string>
<string name="scene_notification_title_label">提示标题</string>
<string name="scene_notification_msg_label">提示消息内容</string>
<string name="scene_unit_seconds">%1$d 秒</string>
<!-- Action Card Displays -->
<string name="scene_action_socket">插座【%1$s】: 设为 %2$s</string>
<string name="scene_action_remote">遥控【%1$s】: 发送 %2$s</string>
<string name="scene_action_infrared">红外【%1$s】: 槽位 %2$s</string>
<string name="scene_action_delay">⏱️ 等待 %1$d 秒</string>
<string name="scene_action_notification">🔔 提示: %1$s</string>
<!-- Scene: execution --> <!-- Scene: execution -->
<string name="scene_execution_title">执行情景</string> <string name="scene_execution_title">执行指令</string>
<string name="scene_execution_run">执行</string> <string name="scene_execution_run">执行</string>
<string name="scene_execution_running">正在执行 %1$s…</string> <string name="scene_execution_running">正在执行:%1$s…</string>
<string name="scene_execution_summary">%3$d 个动作中成功 %1$d 个,失败 %2$d 个</string> <string name="scene_execution_summary">%3$d 个动作中成功 %1$d 个,失败 %2$d 个</string>
<string name="scene_execution_done">完成</string> <string name="scene_execution_done">执行完成</string>
<string name="scene_execution_duration">总耗时:%1$s ms</string>
<string name="scene_step_pending">排队中</string>
<string name="scene_step_running">正在执行…</string>
<string name="scene_step_delaying">正在延时等待 (%1$d s)…</string>
<string name="scene_step_success">已完成</string>
<string name="scene_step_failed">执行失败</string>
<string name="scene_step_skipped">已跳过</string>
<!-- 设备列表 --> <!-- 设备列表 -->
<string name="device_list_title">设备列表</string> <string name="device_list_title">设备列表</string>
+105 -25
View File
@@ -42,44 +42,124 @@
<string name="activity_placeholder_title">Activity Log</string> <string name="activity_placeholder_title">Activity Log</string>
<string name="activity_placeholder_description">No recorded events yet.</string> <string name="activity_placeholder_description">No recorded events yet.</string>
<!-- Scene: list --> <!-- Scene & Automation strings -->
<string name="scene_list_title">Scenes</string> <string name="scene_list_title">Automation &amp; Scenes</string>
<string name="scene_create_action">New scene</string> <string name="scene_tab_all">All</string>
<string name="scene_tab_manual">Manual</string>
<string name="scene_tab_automatic">Automatic</string>
<string name="scene_create_manual">New Manual Command</string>
<string name="scene_create_automatic">New Automatic Rule</string>
<string name="scene_create_action">New Command</string>
<string name="scene_action_count">%1$d actions</string> <string name="scene_action_count">%1$d actions</string>
<string name="scene_edit">Edit scene</string> <string name="scene_edit">Edit Command</string>
<string name="scene_delete">Delete scene</string> <string name="scene_delete">Delete Command</string>
<string name="scene_empty_title">No scenes yet</string> <string name="scene_empty_title">No automation commands yet</string>
<string name="scene_empty_message">Create your first scene to combine socket and infrared actions.</string> <string name="scene_empty_message">Tap the button below to create instant commands or trigger-based automatic rules.</string>
<string name="scene_action_run_now">Run Now</string>
<string name="scene_toast_executed">Command triggered</string>
<!-- Scene: delete dialog --> <!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">Delete scene</string> <string name="scene_delete_dialog_title">Delete Command</string>
<string name="scene_delete_dialog_message">This action cannot be undone.</string> <string name="scene_delete_dialog_message">Are you sure you want to delete this command? This cannot be undone.</string>
<string name="scene_delete_confirm">Delete</string> <string name="scene_delete_confirm">Delete</string>
<string name="scene_delete_cancel">Cancel</string> <string name="scene_delete_cancel">Cancel</string>
<!-- Scene: editor --> <!-- Scene: editor -->
<string name="scene_editor_new_title">New scene</string> <string name="scene_editor_manual_title">Create Manual Command</string>
<string name="scene_editor_edit_title">Edit scene</string> <string name="scene_editor_auto_title">Create Automatic Rule</string>
<string name="scene_editor_edit_title">Edit Command</string>
<string name="scene_back">Back</string> <string name="scene_back">Back</string>
<string name="scene_name_label">Name</string> <string name="scene_name_label">Title</string>
<string name="scene_icon_label">Icon</string> <string name="scene_name_hint">Enter title (e.g. Movie Night, High Temp Auto Cooling)</string>
<string name="scene_color_label">Accent color (hex)</string> <string name="scene_description_label">Description</string>
<string name="scene_actions_label">Actions (%1$d)</string> <string name="scene_description_hint">Optional notes or details about this rule</string>
<string name="scene_add_socket_action">Add socket action</string>
<string name="scene_add_infrared_action">Add infrared action</string> <!-- Sections -->
<string name="scene_device_name_label">Device name</string> <string name="scene_section_triggers">Triggers</string>
<string name="scene_infrared_key_label">Infrared key</string> <string name="scene_section_conditions">Conditions (Optional)</string>
<string name="scene_section_actions">Actions</string>
<string name="scene_section_advanced">Advanced Options</string>
<string name="scene_section_other">Other Options &amp; Appearance</string>
<!-- Add Buttons -->
<string name="scene_add_trigger">+ Add Trigger</string>
<string name="scene_add_condition">+ Add Condition</string>
<string name="scene_add_action_btn">+ Add Action</string>
<string name="scene_add_action">Add</string> <string name="scene_add_action">Add</string>
<string name="scene_save_action">Save scene</string> <string name="scene_save_action">Save Command</string>
<string name="scene_action_socket">Socket %1$s -> %2$s</string>
<string name="scene_action_infrared">Infrared %1$s -> %2$s</string> <!-- Strategies & Toggles -->
<string name="scene_repeat_strategy">Repeat Execution Strategy</string>
<string name="scene_strategy_continue">Continue new task</string>
<string name="scene_concurrency_mode">Concurrency Mode</string>
<string name="scene_concurrency_sync">Synchronous (Sequential)</string>
<string name="scene_concurrency_async">Asynchronous (Parallel)</string>
<string name="scene_error_policy">On Error Policy</string>
<string name="scene_policy_continue">Continue subsequent actions</string>
<string name="scene_policy_abort">Abort remaining actions</string>
<string name="scene_appearance_icon">Icon</string>
<string name="scene_appearance_color">Icon Background Color</string>
<string name="scene_enable_rule">Enable this automatic rule</string>
<string name="scene_enable_logging">Enable command execution logs</string>
<!-- Trigger Types & Dialog -->
<string name="scene_dialog_add_trigger_title">Select Trigger Type</string>
<string name="scene_trigger_type_time">⏰ Time / Schedule Trigger</string>
<string name="scene_trigger_type_temp">🌡️ Temperature Threshold</string>
<string name="scene_trigger_type_humidity">💧 Humidity Threshold</string>
<string name="scene_trigger_type_device">🔌 Socket State Changed</string>
<string name="scene_trigger_time_label">Trigger Time (e.g. 07:30)</string>
<string name="scene_trigger_temp_threshold">Temperature Threshold (°C)</string>
<string name="scene_trigger_humidity_threshold">Humidity Threshold (%)</string>
<string name="scene_operator_label">Condition</string>
<string name="scene_operator_gt">Greater than (&gt;)</string>
<string name="scene_operator_lt">Less than (&lt;)</string>
<string name="scene_operator_eq">Equals (=)</string>
<!-- Condition Types & Dialog -->
<string name="scene_dialog_add_condition_title">Select Condition</string>
<string name="scene_condition_type_temp">🌡️ Temperature Range</string>
<string name="scene_condition_type_humidity">💧 Humidity Range</string>
<string name="scene_condition_type_socket">🔌 Current Socket Power</string>
<string name="scene_condition_type_time_range">🕒 Active Time Range</string>
<string name="scene_condition_start_time">Start Time (e.g. 22:00)</string>
<string name="scene_condition_end_time">End Time (e.g. 07:00)</string>
<!-- Action Types & Dialog -->
<string name="scene_dialog_add_action_title">Select Action</string>
<string name="scene_action_type_socket">🔌 Smart Socket Control</string>
<string name="scene_action_type_virtual_remote">📺 Infrared Remote Button</string>
<string name="scene_action_type_delay">⏱️ Delay / Buffer Timer</string>
<string name="scene_action_type_notification">🔔 System Notification</string>
<string name="scene_select_socket">Select Target Socket</string>
<string name="scene_socket_power_state">Target Power State</string>
<string name="scene_select_virtual_remote">Select Virtual Remote</string>
<string name="scene_select_remote_button">Select Remote Key</string>
<string name="scene_delay_duration_label">Delay Duration (seconds)</string>
<string name="scene_notification_title_label">Notification Title</string>
<string name="scene_notification_msg_label">Notification Message</string>
<string name="scene_unit_seconds">%1$d sec</string>
<!-- Action Card Displays -->
<string name="scene_action_socket">Socket [%1$s]: Set %2$s</string>
<string name="scene_action_remote">Remote [%1$s]: Send %2$s</string>
<string name="scene_action_infrared">Infrared [%1$s]: Slot %2$s</string>
<string name="scene_action_delay">⏱️ Delay %1$d seconds</string>
<string name="scene_action_notification">🔔 Notify: %1$s</string>
<!-- Scene: execution --> <!-- Scene: execution -->
<string name="scene_execution_title">Run scene</string> <string name="scene_execution_title">Execute Command</string>
<string name="scene_execution_run">Run</string> <string name="scene_execution_run">Run</string>
<string name="scene_execution_running">Running %1$s…</string> <string name="scene_execution_running">Running: %1$s…</string>
<string name="scene_execution_summary">%1$d succeeded, %2$d failed of %3$d actions</string> <string name="scene_execution_summary">%1$d succeeded, %2$d failed of %3$d actions</string>
<string name="scene_execution_done">Done</string> <string name="scene_execution_done">Execution Completed</string>
<string name="scene_execution_duration">Total Elapsed: %1$s ms</string>
<string name="scene_step_pending">Pending</string>
<string name="scene_step_running">Running…</string>
<string name="scene_step_delaying">Waiting (%1$d s)…</string>
<string name="scene_step_success">Completed</string>
<string name="scene_step_failed">Failed</string>
<string name="scene_step_skipped">Skipped</string>
<!-- Device list --> <!-- Device list -->
<string name="device_list_title">Device List</string> <string name="device_list_title">Device List</string>
@@ -1,6 +1,10 @@
package com.flagship.abox.manager.ui.scene 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.DeviceName
import com.flagship.abox.manager.domain.ExecutionStrategy
import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType 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.SceneAction
import com.flagship.abox.manager.domain.SceneActionId import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor 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.SceneIcon
import com.flagship.abox.manager.domain.SceneId 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.SocketPowerState
import com.flagship.abox.manager.testing.MainDispatcherRule import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -31,6 +38,7 @@ class SceneEditorViewModelTest {
private val profileStore = FakeProviderProfileStore() private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository() private val sceneRepository = FakeSceneRepository()
private val virtualRemoteRepository = FakeVirtualRemoteRepository()
private val profileId = ProviderProfileId("local-experience") private val profileId = ProviderProfileId("local-experience")
private val existingScene = Scene( private val existingScene = Scene(
@@ -58,8 +66,8 @@ class SceneEditorViewModelTest {
@Test @Test
fun initializeWithNullPreparesNewScene() = runTest { fun initializeWithNullPreparesNewScene() = runTest {
setupProfile() setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore) val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null) viewModel.initialize(null, AutomationType.MANUAL)
val state = viewModel.state.value val state = viewModel.state.value
assertFalse(state.isEditing) assertFalse(state.isEditing)
@@ -71,7 +79,7 @@ class SceneEditorViewModelTest {
@Test @Test
fun initializeWithExistingSceneLoadsState() = runTest { fun initializeWithExistingSceneLoadsState() = runTest {
setupProfile() setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore) val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(existingScene.id) viewModel.initialize(existingScene.id)
advanceUntilIdle() advanceUntilIdle()
@@ -85,34 +93,60 @@ class SceneEditorViewModelTest {
@Test @Test
fun addingAndReorderingActionsUpdatesState() = runTest { fun addingAndReorderingActionsUpdatesState() = runTest {
setupProfile() setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore) val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null) viewModel.initialize(null, AutomationType.MANUAL)
viewModel.updateName("My New Scene") viewModel.updateName("My New Scene")
viewModel.addSocketAction("socket-1", SocketPowerState.ON) viewModel.addSocketAction("socket-1", SocketPowerState.ON)
viewModel.addInfraredAction("ir-1", "power") viewModel.addDelayAction(3)
viewModel.addNotificationAction("Done", "Completed")
advanceUntilIdle() 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[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 // Move second to first
viewModel.moveAction(1, 0) 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) assertTrue(viewModel.state.value.actions[1] is SceneAction.SocketPower)
// Remove first action // Remove first action
val firstId = viewModel.state.value.actions[0].id val firstId = viewModel.state.value.actions[0].id
viewModel.removeAction(firstId) 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 @Test
fun saveNewScenePersistsToRepository() = runTest { fun saveNewScenePersistsToRepository() = runTest {
setupProfile() setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore) val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null) viewModel.initialize(null, AutomationType.MANUAL)
viewModel.updateName("Brand New Scene") viewModel.updateName("Brand New Scene")
viewModel.addSocketAction("socket-main", SocketPowerState.OFF) viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
advanceUntilIdle() advanceUntilIdle()
@@ -125,3 +159,4 @@ class SceneEditorViewModelTest {
assertFalse(viewModel.state.value.isSaving) assertFalse(viewModel.state.value.isSaving)
} }
} }
@@ -1,5 +1,6 @@
package com.flagship.abox.manager.ui.scene 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.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId 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.SceneId
import com.flagship.abox.manager.domain.SocketPowerState import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.MainDispatcherRule 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.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
@@ -33,12 +38,16 @@ class SceneListViewModelTest {
private val profileStore = FakeProviderProfileStore() private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository() 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 profileId = ProviderProfileId("local-experience")
private val testScene = Scene( private val manualScene = Scene(
id = SceneId("local-experience:scene-1"), id = SceneId("local-experience:scene-1"),
profileId = profileId, profileId = profileId,
name = "Test Scene", name = "Manual Scene",
type = AutomationType.MANUAL,
icon = SceneIcon.HOME, icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"), color = SceneColor("#FF8A65"),
actions = listOf( 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 @Test
fun loadsScenesForActiveProfile() = runTest { fun loadsScenesForActiveProfile() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId) 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)) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {} viewModel.scenes.collect {}
} }
advanceUntilIdle() 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(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 @Test
fun deleteSceneRemovesFromRepositoryAndClearsPending() = runTest { fun deleteSceneRemovesFromRepositoryAndClearsPending() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId) 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)) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {} viewModel.scenes.collect {}
} }
advanceUntilIdle() advanceUntilIdle()
viewModel.markPendingDelete(testScene.id) viewModel.markPendingDelete(manualScene.id)
assertEquals(testScene.id, viewModel.pendingDelete.value) assertEquals(manualScene.id, viewModel.pendingDelete.value)
viewModel.deleteScene(testScene.id) viewModel.deleteScene(manualScene.id)
advanceUntilIdle() advanceUntilIdle()
assertNull(viewModel.pendingDelete.value) assertNull(viewModel.pendingDelete.value)
@@ -91,11 +159,12 @@ class SceneListViewModelTest {
@Test @Test
fun cancelDeleteClearsPendingState() = runTest { fun cancelDeleteClearsPendingState() = runTest {
val viewModel = SceneListViewModel(sceneRepository, profileStore) val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.markPendingDelete(testScene.id) viewModel.markPendingDelete(manualScene.id)
assertEquals(testScene.id, viewModel.pendingDelete.value) assertEquals(manualScene.id, viewModel.pendingDelete.value)
viewModel.cancelDelete() viewModel.cancelDelete()
assertNull(viewModel.pendingDelete.value) assertNull(viewModel.pendingDelete.value)
} }
} }
@@ -1,20 +1,21 @@
package com.flagship.abox.manager.domain 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 import java.util.concurrent.ConcurrentHashMap
/** /**
* Production implementation of [SceneExecutor]. * Production implementation of [SceneExecutor].
* *
* Executes scene actions sequentially via [SocketGateway] and [InfraredGateway]. * Executes scene actions via [SocketGateway], [InfraredGateway], [SendVirtualRemoteAction],
* Guarantees: * and built-in timers.
* 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].
*/ */
class DefaultSceneExecutor( class DefaultSceneExecutor(
private val socketGateway: SocketGateway, private val socketGateway: SocketGateway,
private val infraredGateway: InfraredGateway, private val infraredGateway: InfraredGateway,
private val sendVirtualRemoteAction: SendVirtualRemoteAction? = null,
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
) : SceneExecutor { ) : SceneExecutor {
private val runningScenes = ConcurrentHashMap<SceneId, Boolean>() private val runningScenes = ConcurrentHashMap<SceneId, Boolean>()
@@ -27,9 +28,41 @@ class DefaultSceneExecutor(
), ),
) )
} }
val startTime = System.currentTimeMillis()
val results = mutableListOf<SceneActionExecutionResult>()
var aborted = false
return try { return try {
val results = scene.actions.map { action -> executeAction(scene.profileId, action) } for (action in scene.actions) {
DomainResult.Success(SceneExecutionResult(scene = scene, results = results)) 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 { } finally {
runningScenes.remove(scene.id) runningScenes.remove(scene.id)
} }
Outdated
Review

并行失效

执行逻辑完全未读取 scene.concurrencyMode,所有动作始终通过循环串行执行。用户选择 PARALLEL 后,延时等动作仍会阻塞后续动作,使编辑器中的并行设置失效,并显著改变执行时长和预期行为。

**并行失效** 执行逻辑完全未读取 `scene.concurrencyMode`,所有动作始终通过循环串行执行。用户选择 `PARALLEL` 后,延时等动作仍会阻塞后续动作,使编辑器中的并行设置失效,并显著改变执行时长和预期行为。 <!-- pr-agent-dedup: 7e22b393cc56 --> <!-- pr-agent-key-issue-location: 2a617e7fe21b -->
@@ -39,30 +72,80 @@ class DefaultSceneExecutor(
profileId: ProviderProfileId, profileId: ProviderProfileId,
action: SceneAction, action: SceneAction,
): SceneActionExecutionResult { ): SceneActionExecutionResult {
val result = when (action) { return when (action) {
is SceneAction.SocketPower -> socketGateway.setPower( is SceneAction.SocketPower -> {
val res = socketGateway.setPower(
profileId = profileId, profileId = profileId,
deviceName = action.deviceName, deviceName = action.deviceName,
powerState = action.powerState, powerState = action.powerState,
) )
toExecutionResult(action, res)
}
is SceneAction.InfraredKey -> infraredGateway.sendKey( is SceneAction.InfraredKey -> {
val res = infraredGateway.sendKey(
profileId = profileId, profileId = profileId,
deviceName = action.deviceName, deviceName = action.deviceName,
key = action.key, key = action.key,
) )
toExecutionResult(action, res)
} }
return when (result) {
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,
)
}
}
}
private fun toExecutionResult(action: SceneAction, res: DomainResult<*>): SceneActionExecutionResult {
return when (res) {
is DomainResult.Success -> SceneActionExecutionResult( is DomainResult.Success -> SceneActionExecutionResult(
action = action, action = action,
outcome = SceneActionOutcome.SUCCESS, outcome = SceneActionOutcome.SUCCESS,
error = null, error = null,
) )
is DomainResult.Failure -> SceneActionExecutionResult( is DomainResult.Failure -> SceneActionExecutionResult(
action = action, action = action,
outcome = SceneActionOutcome.FAILURE, outcome = SceneActionOutcome.FAILURE,
error = result.error, error = res.error,
) )
} }
} }
@@ -96,36 +96,63 @@ class InMemorySceneRepository : SceneRepository {
Scene( Scene(
id = SceneId("${profileId.value}:scene-leaving-home"), id = SceneId("${profileId.value}:scene-leaving-home"),
profileId = profileId, profileId = profileId,
name = "Leaving home", name = "离家模式",
description = "关闭插座并延时关闭电视",
type = AutomationType.MANUAL,
enabled = true,
icon = SceneIcon.AWAY, icon = SceneIcon.AWAY,
color = SceneColor("#FF8A65"), color = SceneColor("#FF8A65"),
actions = listOf( actions = listOf(
SceneAction.SocketPower( SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"), id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"),
profileId = profileId, profileId = profileId,
deviceName = DeviceName("socket-1"), deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.OFF, powerState = SocketPowerState.OFF,
), ),
SceneAction.Delay(
id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"),
profileId = profileId,
durationSeconds = 1,
),
SceneAction.InfraredKey( SceneAction.InfraredKey(
id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"), id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"),
profileId = profileId, profileId = profileId,
deviceName = DeviceName("ir-1"), deviceName = DeviceName("红外控制器"),
key = "power", key = "1",
), ),
), ),
), ),
Scene( Scene(
id = SceneId("${profileId.value}:scene-movie-night"), id = SceneId("${profileId.value}:scene-cooling-auto"),
profileId = profileId, profileId = profileId,
name = "Movie night", name = "高温自动降温",
icon = SceneIcon.MOVIE, description = "当室温高于 28°C 时开启插座",
color = SceneColor("#7986CB"), 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( actions = listOf(
SceneAction.SocketPower( SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-movie-night:socket-1"), id = SceneActionId("${profileId.value}:scene-cooling:socket-1"),
profileId = profileId, profileId = profileId,
deviceName = DeviceName("socket-1"), deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.OFF, powerState = SocketPowerState.ON,
), ),
), ),
), ),
@@ -6,51 +6,164 @@ value class SceneId(val value: String)
@JvmInline @JvmInline
value class SceneActionId(val value: String) 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 { enum class SceneActionType {
SOCKET_POWER, SOCKET_POWER,
VIRTUAL_REMOTE_BUTTON,
INFRARED_KEY, 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<Int> = 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]. * 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 { sealed interface SceneAction {
val id: SceneActionId val id: SceneActionId
val profileId: ProviderProfileId val profileId: ProviderProfileId
val deviceName: DeviceName
val type: SceneActionType val type: SceneActionType
data class SocketPower( data class SocketPower(
override val id: SceneActionId, override val id: SceneActionId,
override val profileId: ProviderProfileId, override val profileId: ProviderProfileId,
override val deviceName: DeviceName, val deviceName: DeviceName,
val powerState: SocketPowerState, val powerState: SocketPowerState,
) : SceneAction { ) : SceneAction {
override val type: SceneActionType get() = SceneActionType.SOCKET_POWER 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( data class InfraredKey(
override val id: SceneActionId, override val id: SceneActionId,
override val profileId: ProviderProfileId, override val profileId: ProviderProfileId,
override val deviceName: DeviceName, val deviceName: DeviceName,
val key: String, val key: String,
) : SceneAction { ) : SceneAction {
override val type: SceneActionType get() = SceneActionType.INFRARED_KEY 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 * Visual identity for a scene.
* external resources and so the persistence layer stays stable across icon set changes.
*/ */
enum class SceneIcon { enum class SceneIcon {
HOME, HOME,
@@ -58,12 +171,17 @@ enum class SceneIcon {
NIGHT, NIGHT,
MOVIE, MOVIE,
AWAY, AWAY,
READING,
DINING,
GAMING,
THERMOSTAT,
LIGHT,
CLEAN,
CUSTOM, CUSTOM,
} }
/** /**
* Scene accent color stored as an uppercase hex string (e.g. `#FF8A65`). The UI maps it to the * Scene accent color stored as an uppercase hex string (e.g. `#3F51B5`).
* Compose color space; persistence stores it verbatim.
*/ */
@JvmInline @JvmInline
value class SceneColor(val hex: String) value class SceneColor(val hex: String)
@@ -72,7 +190,15 @@ data class Scene(
val id: SceneId, val id: SceneId,
val profileId: ProviderProfileId, val profileId: ProviderProfileId,
val name: String, val name: String,
val icon: SceneIcon, val description: String = "",
val color: SceneColor, val type: AutomationType = AutomationType.MANUAL,
val actions: List<SceneAction>, val enabled: Boolean = true,
val icon: SceneIcon = SceneIcon.HOME,
val color: SceneColor = SceneColor("#3F51B5"),
val triggers: List<SceneTrigger> = emptyList(),
val conditions: List<SceneCondition> = emptyList(),
val actions: List<SceneAction> = emptyList(),
val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR,
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
val enableLogging: Boolean = true,
) )
@@ -3,6 +3,7 @@ package com.flagship.abox.manager.domain
enum class SceneActionOutcome { enum class SceneActionOutcome {
SUCCESS, SUCCESS,
FAILURE, FAILURE,
SKIPPED,
} }
/** /**
@@ -12,21 +13,24 @@ data class SceneActionExecutionResult(
val action: SceneAction, val action: SceneAction,
val outcome: SceneActionOutcome, val outcome: SceneActionOutcome,
val error: DomainError?, val error: DomainError?,
val executionDurationMillis: Long = 0L,
) )
/** /**
* Aggregated result of executing a [Scene]. Actions are executed sequentially in the order they * Aggregated result of executing a [Scene].
* appear in [scene.actions]; a per-action failure does not abort subsequent actions.
*/ */
data class SceneExecutionResult( data class SceneExecutionResult(
val scene: Scene, val scene: Scene,
val results: List<SceneActionExecutionResult>, val results: List<SceneActionExecutionResult>,
val totalDurationMillis: Long = 0L,
) { ) {
val totalActions: Int get() = results.size val totalActions: Int get() = results.size
val succeededCount: Int val succeededCount: Int
get() = results.count { it.outcome == SceneActionOutcome.SUCCESS } get() = results.count { it.outcome == SceneActionOutcome.SUCCESS }
val failedCount: Int val failedCount: Int
get() = results.count { it.outcome == SceneActionOutcome.FAILURE } get() = results.count { it.outcome == SceneActionOutcome.FAILURE }
val skippedCount: Int
get() = results.count { it.outcome == SceneActionOutcome.SKIPPED }
val allSucceeded: Boolean val allSucceeded: Boolean
get() = results.isNotEmpty() && failedCount == 0 get() = results.isNotEmpty() && failedCount == 0 && skippedCount == 0
} }
@@ -149,4 +149,77 @@ class DefaultSceneExecutorTest {
assertEquals(1, socketGateway.calls.size) assertEquals(1, socketGateway.calls.size)
assertEquals(1, infraredGateway.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)
}
}
@@ -181,12 +181,6 @@ class FakeSceneRepository : SceneRepository {
/** /**
* Fake [SceneExecutor] that drives the existing [SocketGateway] and [InfraredGateway] fakes. * 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( class FakeSceneExecutor(
private val socketGateway: SocketGateway, private val socketGateway: SocketGateway,
@@ -203,9 +197,23 @@ class FakeSceneExecutor(
), ),
) )
} }
val startTime = System.currentTimeMillis()
val results = mutableListOf<SceneActionExecutionResult>()
return try { return try {
val results = scene.actions.map { action -> executeAction(scene.profileId, action) } for (action in scene.actions) {
DomainResult.Success(SceneExecutionResult(scene = scene, results = results)) 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 { } finally {
running.remove(scene.id) running.remove(scene.id)
} }
@@ -227,6 +235,10 @@ class FakeSceneExecutor(
deviceName = action.deviceName, deviceName = action.deviceName,
key = action.key, key = action.key,
Outdated
Review

策略失效

PARALLEL 分支一次性启动所有动作,完全未处理 scene.strategy。当用户组合选择并行与 ABORT_ON_ERROR 时,即使一个动作失败,其余插座或红外等副作用仍会执行,使“遇错中止”失效。应禁止该组合,或在失败后取消尚未完成的任务并明确已启动动作的语义。

**策略失效** `PARALLEL` 分支一次性启动所有动作,完全未处理 `scene.strategy`。当用户组合选择并行与 `ABORT_ON_ERROR` 时,即使一个动作失败,其余插座或红外等副作用仍会执行,使“遇错中止”失效。应禁止该组合,或在失败后取消尚未完成的任务并明确已启动动作的语义。 <!-- pr-agent-dedup: 7ef796cc7fb8 --> <!-- pr-agent-key-issue-location: d6ce8a1fade9 -->
) )
is SceneAction.VirtualRemoteButton,
is SceneAction.Delay,
is SceneAction.Notification -> DomainResult.Success(Unit)
} }
return when (result) { return when (result) {
is DomainResult.Success -> SceneActionExecutionResult( is DomainResult.Success -> SceneActionExecutionResult(