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.SceneRepository
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -27,5 +28,10 @@ object SceneIntegrationModule {
fun provideSceneExecutor(
socketGateway: SocketGateway,
infraredGateway: InfraredGateway,
): SceneExecutor = DefaultSceneExecutor(socketGateway, infraredGateway)
sendVirtualRemoteAction: SendVirtualRemoteAction,
): SceneExecutor = DefaultSceneExecutor(
socketGateway = socketGateway,
infraredGateway = infraredGateway,
sendVirtualRemoteAction = sendVirtualRemoteAction,
)
}
@@ -130,7 +130,8 @@ private fun AuthenticatedNavHost(
},
scenesSlot = {
SceneListScreen(
onAddScene = { navController.navigate(SceneRoutes.EDITOR) },
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
)
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.viewModelScope
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.ComparisonOperator
import com.flagship.abox.manager.domain.ConcurrencyMode
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ExecutionStrategy
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneCondition
import com.flagship.abox.manager.domain.SceneConditionId
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SceneTrigger
import com.flagship.abox.manager.domain.SceneTriggerId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SceneEditorState(
val sceneId: SceneId? = null,
val type: AutomationType = AutomationType.MANUAL,
val name: String = "",
val description: String = "",
val enabled: Boolean = true,
val icon: SceneIcon = SceneIcon.HOME,
val colorHex: String = "#FF8A65",
val colorHex: String = "#3F51B5",
val triggers: List<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,
val availableRemotes: List<VirtualRemote> = emptyList(),
val isSaving: Boolean = false,
val isSaved: Boolean = false,
val errorMessage: String? = null,
@@ -39,15 +61,40 @@ data class SceneEditorState(
@HiltViewModel
class SceneEditorViewModel @Inject constructor(
private val sceneRepository: SceneRepository,
private val virtualRemoteRepository: VirtualRemoteRepository,
private val profileStore: ProviderProfileStore,
) : ViewModel() {
private val _state = MutableStateFlow(SceneEditorState())
val state: StateFlow<SceneEditorState> = _state.asStateFlow()
fun initialize(sceneId: SceneId?) {
init {
observeAvailableRemotes()
}
private fun observeAvailableRemotes() {
viewModelScope.launch {
profileStore.observeActiveProfile().collectLatest { profile ->
if (profile != null) {
virtualRemoteRepository.observeRemotes(profile.id, DeviceName("红外控制器")).collectLatest { res ->
if (res is DomainResult.Success) {
_state.update { it.copy(availableRemotes = res.value) }
}
}
}
}
}
}
fun initialize(sceneId: SceneId?, initialType: AutomationType? = null) {
if (sceneId == null) {
_state.value = SceneEditorState()
_state.update {
SceneEditorState(
type = initialType ?: AutomationType.MANUAL,
colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5",
availableRemotes = it.availableRemotes,
)
}
return
}
viewModelScope.launch {
@@ -58,13 +105,23 @@ class SceneEditorViewModel @Inject constructor(
}
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
if (scene != null) {
_state.value = SceneEditorState(
_state.update {
it.copy(
sceneId = scene.id,
type = scene.type,
name = scene.name,
description = scene.description,
enabled = scene.enabled,
icon = scene.icon,
colorHex = scene.color.hex,
triggers = scene.triggers,
conditions = scene.conditions,
actions = scene.actions,
strategy = scene.strategy,
concurrencyMode = scene.concurrencyMode,
enableLogging = scene.enableLogging,
)
}
} else {
_state.update { it.copy(errorMessage = "Scene not found") }
}
@@ -72,11 +129,104 @@ class SceneEditorViewModel @Inject constructor(
}
fun updateName(name: String) = _state.update { it.copy(name = name, errorMessage = null) }
fun updateDescription(desc: String) = _state.update { it.copy(description = desc) }
fun updateType(type: AutomationType) = _state.update { it.copy(type = type) }
fun updateEnabled(enabled: Boolean) = _state.update { it.copy(enabled = enabled) }
fun updateIcon(icon: SceneIcon) = _state.update { it.copy(icon = icon) }
fun updateColor(hex: String) = _state.update { it.copy(colorHex = hex) }
fun updateStrategy(strategy: ExecutionStrategy) = _state.update { it.copy(strategy = strategy) }
fun updateConcurrencyMode(mode: ConcurrencyMode) = _state.update { it.copy(concurrencyMode = mode) }
fun updateLogging(enabled: Boolean) = _state.update { it.copy(enableLogging = enabled) }
// --- Triggers ---
fun addTimeTrigger(timeOfDay: String, daysOfWeek: Set<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) {
if (deviceName.isBlank()) return
viewModelScope.launch {
@@ -91,6 +241,26 @@ class SceneEditorViewModel @Inject constructor(
}
}
fun addVirtualRemoteAction(
remoteId: String,
remoteDisplayName: String,
buttonId: String,
buttonDisplayName: String,
) {
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val action = SceneAction.VirtualRemoteButton(
id = SceneActionId(UUID.randomUUID().toString()),
profileId = profile.id,
remoteId = remoteId,
remoteDisplayName = remoteDisplayName,
buttonId = buttonId,
buttonDisplayName = buttonDisplayName,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
}
fun addInfraredAction(deviceName: String, key: String) {
if (deviceName.isBlank() || key.isBlank()) return
viewModelScope.launch {
@@ -105,6 +275,33 @@ class SceneEditorViewModel @Inject constructor(
}
}
fun addDelayAction(seconds: Int) {
if (seconds <= 0) return
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val action = SceneAction.Delay(
id = SceneActionId(UUID.randomUUID().toString()),
profileId = profile.id,
durationSeconds = seconds,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
}
fun addNotificationAction(title: String, message: String) {
if (title.isBlank()) return
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val action = SceneAction.Notification(
id = SceneActionId(UUID.randomUUID().toString()),
profileId = profile.id,
title = title,
message = message,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
}
fun removeAction(actionId: SceneActionId) =
_state.update { it.copy(actions = it.actions.filterNot { a -> a.id == actionId }) }
@@ -115,6 +312,13 @@ class SceneEditorViewModel @Inject constructor(
_state.update { it.copy(actions = updated) }
}
suspend fun getKeyBindings(remoteId: VirtualRemoteId): List<RemoteKeyBinding> {
return when (val res = virtualRemoteRepository.getKeyBindings(remoteId)) {
is DomainResult.Success -> res.value
is DomainResult.Failure -> emptyList()
}
}
fun save() {
val state = _state.value
if (!state.isValid || state.isSaving) return
@@ -129,9 +333,17 @@ class SceneEditorViewModel @Inject constructor(
id = state.sceneId ?: SceneId("${profile.id.value}:scene-${UUID.randomUUID()}"),
profileId = profile.id,
name = state.name.trim(),
description = state.description.trim(),
type = state.type,
enabled = state.enabled,
icon = state.icon,
color = SceneColor(state.colorHex),
triggers = state.triggers,
conditions = state.conditions,
actions = state.actions,
strategy = state.strategy,
concurrencyMode = state.concurrencyMode,
enableLogging = state.enableLogging,
)
val result = if (state.isEditing) {
sceneRepository.updateScene(profile.id, scene)
@@ -151,3 +363,4 @@ class SceneEditorViewModel @Inject constructor(
}
}
@@ -1,28 +1,39 @@
package com.flagship.abox.manager.ui.scene
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.outlined.HourglassDisabled
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -30,8 +41,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -39,21 +50,25 @@ import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionExecutionResult
import com.flagship.abox.manager.domain.SceneActionOutcome
import com.flagship.abox.manager.domain.SceneExecutionResult
import com.flagship.abox.manager.domain.SceneId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SceneExecutionScreen(
sceneId: com.flagship.abox.manager.domain.SceneId,
sceneId: SceneId,
onBack: () -> Unit,
viewModel: SceneExecutionViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(sceneId) { viewModel.initialize(sceneId) }
LaunchedEffect(sceneId) {
viewModel.initialize(sceneId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.scene_execution_title)) },
title = { Text(stringResource(R.string.scene_execution_title), fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back))
@@ -64,29 +79,28 @@ fun SceneExecutionScreen(
) { innerPadding ->
when (val state = uiState) {
SceneExecutionUiState.Loading -> CenteredLoading(innerPadding)
is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message)
is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message, onRetry = { viewModel.initialize(sceneId) })
is SceneExecutionUiState.Ready -> ReadyContent(innerPadding, state.scene.name, viewModel::execute)
is SceneExecutionUiState.Running -> RunningContent(innerPadding, state.scene.name)
is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack)
is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack, onRerun = viewModel::execute)
}
}
}
@Composable
private fun CenteredLoading(paddingValues: PaddingValues) {
Row(
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
@Composable
private fun CenteredMessage(paddingValues: PaddingValues, message: String) {
private fun CenteredMessage(paddingValues: PaddingValues, message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -96,6 +110,12 @@ private fun CenteredMessage(paddingValues: PaddingValues, message: String) {
verticalArrangement = Arrangement.Center,
) {
Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(16.dp))
Button(onClick = onRetry) {
Icon(Icons.Filled.Refresh, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("重试")
}
}
}
@@ -113,10 +133,18 @@ private fun ReadyContent(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(sceneName, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.size(24.dp))
Button(onClick = onExecute) {
Text(stringResource(R.string.scene_execution_run))
Text(sceneName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text("点击下方按钮立即开始执行", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(24.dp))
Button(
onClick = onExecute,
modifier = Modifier.fillMaxWidth(0.6f).height(48.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.scene_execution_run), fontWeight = FontWeight.SemiBold)
}
}
}
@@ -131,11 +159,12 @@ private fun RunningContent(paddingValues: PaddingValues, sceneName: String) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
Spacer(Modifier.size(16.dp))
CircularProgressIndicator(modifier = Modifier.size(56.dp))
Spacer(Modifier.height(20.dp))
Text(
stringResource(R.string.scene_execution_running, sceneName),
style = MaterialTheme.typography.bodyLarge,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
)
}
}
@@ -143,8 +172,9 @@ private fun RunningContent(paddingValues: PaddingValues, sceneName: String) {
@Composable
private fun CompletedContent(
paddingValues: PaddingValues,
result: com.flagship.abox.manager.domain.SceneExecutionResult,
result: SceneExecutionResult,
onBack: () -> Unit,
onRerun: () -> Unit,
) {
LazyColumn(
modifier = Modifier
@@ -153,7 +183,25 @@ private fun CompletedContent(
contentPadding = PaddingValues(16.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 = stringResource(
R.string.scene_execution_summary,
@@ -161,49 +209,105 @@ private fun CompletedContent(
result.failedCount,
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
private fun ActionResultRow(entry: SceneActionExecutionResult) {
Card(modifier = Modifier.fillMaxWidth()) {
private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) {
Icons.Filled.Check
} else {
Icons.Filled.Close
Surface(
shape = CircleShape,
color = when (entry.outcome) {
SceneActionOutcome.SUCCESS -> MaterialTheme.colorScheme.primaryContainer
SceneActionOutcome.FAILURE -> MaterialTheme.colorScheme.errorContainer
SceneActionOutcome.SKIPPED -> MaterialTheme.colorScheme.surfaceVariant
},
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center) {
when (entry.outcome) {
SceneActionOutcome.SUCCESS -> Icon(
Icons.Filled.Check,
contentDescription = null,
tint = if (entry.outcome == SceneActionOutcome.SUCCESS) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
},
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
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)) {
Text(
text = entry.action.describe(),
text = "步骤 $stepNumber: ${entry.action.describe()}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
entry.error?.detail?.let { detail ->
Spacer(Modifier.height(2.dp))
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
@@ -211,11 +315,22 @@ private fun ActionResultRow(entry: SceneActionExecutionResult) {
)
}
}
if (entry.executionDurationMillis > 0) {
Text(
text = "${entry.executionDurationMillis}ms",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
private fun SceneAction.describe(): String = when (this) {
is SceneAction.SocketPower -> "Socket ${deviceName.value} -> ${powerState.name}"
is SceneAction.InfraredKey -> "Infrared ${deviceName.value} -> $key"
is SceneAction.SocketPower -> "插座【${deviceName.value}】-> ${powerState.name}"
is SceneAction.VirtualRemoteButton -> "遥控【$remoteDisplayName】-> $buttonDisplayName"
is SceneAction.InfraredKey -> "红外【${deviceName.value}】-> 槽位 $key"
is SceneAction.Delay -> "⏱️ 等待 ${durationSeconds} 秒"
is SceneAction.Notification -> "🔔 提示: $title"
}
@@ -1,5 +1,6 @@
package com.flagship.abox.manager.ui.scene
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -10,60 +11,119 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SceneListScreen(
onAddScene: () -> Unit,
onAddManual: () -> Unit,
onAddAutomatic: () -> Unit,
onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null,
viewModel: SceneListViewModel = hiltViewModel(),
) {
val scenes by viewModel.scenes.collectAsStateWithLifecycle()
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
var showFabMenu by remember { mutableStateOf(false) }
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.userMessage.collectLatest { msg ->
if (msg == "executed") {
Toast.makeText(context, context.getString(R.string.scene_toast_executed), Toast.LENGTH_SHORT).show()
}
}
}
Scaffold(
floatingActionButton = {
Box {
ExtendedFloatingActionButton(
onClick = onAddScene,
onClick = { showFabMenu = true },
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text(stringResource(R.string.scene_create_action)) },
)
DropdownMenu(
expanded = showFabMenu,
onDismissRequest = { showFabMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.scene_create_manual)) },
leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) },
onClick = {
showFabMenu = false
onAddManual()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.scene_create_automatic)) },
leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) },
onClick = {
showFabMenu = false
onAddAutomatic()
},
)
}
}
},
) { innerPadding ->
LazyColumn(
@@ -71,7 +131,7 @@ fun SceneListScreen(
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
top = 12.dp,
bottom = innerPadding.calculateBottomPadding() + 80.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -81,15 +141,48 @@ fun SceneListScreen(
headerContent()
}
}
item(key = "tabs") {
SecondaryTabRow(
selectedTabIndex = selectedTab.ordinal,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
) {
SceneFilterTab.entries.forEach { tab ->
Tab(
selected = selectedTab == tab,
onClick = { viewModel.selectTab(tab) },
text = {
Text(
text = when (tab) {
SceneFilterTab.ALL -> stringResource(R.string.scene_tab_all)
SceneFilterTab.MANUAL -> stringResource(R.string.scene_tab_manual)
SceneFilterTab.AUTOMATIC -> stringResource(R.string.scene_tab_automatic)
},
fontWeight = if (selectedTab == tab) FontWeight.Bold else FontWeight.Normal,
)
},
)
}
}
}
if (scenes.isEmpty()) {
item(key = "empty") {
EmptySceneList(modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp))
}
} else {
items(scenes, key = { it.id.value }) { scene ->
SceneRow(
SceneCard(
scene = scene,
onClick = { onExecuteScene(scene.id) },
onClick = {
if (scene.type == AutomationType.MANUAL) {
onExecuteScene(scene.id)
} else {
onEditScene(scene.id)
}
},
onQuickExecute = { viewModel.executeQuick(scene) },
onToggleEnabled = { viewModel.toggleEnabled(scene) },
onEdit = { onEditScene(scene.id) },
onDelete = { viewModel.markPendingDelete(scene.id) },
)
@@ -105,7 +198,7 @@ fun SceneListScreen(
text = { Text(stringResource(R.string.scene_delete_dialog_message)) },
confirmButton = {
TextButton(onClick = { viewModel.deleteScene(sceneId) }) {
Text(stringResource(R.string.scene_delete_confirm))
Text(stringResource(R.string.scene_delete_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
@@ -118,55 +211,166 @@ fun SceneListScreen(
}
@Composable
private fun SceneRow(
private fun SceneCard(
scene: Scene,
onClick: () -> Unit,
onQuickExecute: () -> Unit,
onToggleEnabled: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
),
) {
Column(
modifier = Modifier
.clickable(onClick = onClick)
.padding(16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Box(
modifier = Modifier
.size(40.dp)
.size(44.dp)
.background(
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)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = scene.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
TypeBadge(type = scene.type)
}
if (scene.description.isNotBlank()) {
Spacer(Modifier.height(2.dp))
Text(
text = stringResource(
R.string.scene_action_count,
scene.actions.size,
),
text = scene.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
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),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
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,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "⚡",
style = MaterialTheme.typography.displayMedium,
)
Spacer(Modifier.size(8.dp))
Text(
text = stringResource(R.string.scene_empty_title),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.size(12.dp))
Spacer(Modifier.size(8.dp))
Text(
text = stringResource(R.string.scene_empty_message),
style = MaterialTheme.typography.bodyMedium,
@@ -194,8 +404,24 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
}
}
internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? {
fun SceneIcon.displayEmoji(): String = when (this) {
SceneIcon.HOME -> "🏠"
SceneIcon.OFFICE -> "💼"
SceneIcon.NIGHT -> "🌙"
SceneIcon.MOVIE -> "🎬"
SceneIcon.AWAY -> "🚶"
SceneIcon.READING -> "📖"
SceneIcon.DINING -> "🍽️"
SceneIcon.GAMING -> "🎮"
SceneIcon.THERMOSTAT -> "🌡️"
SceneIcon.LIGHT -> "💡"
SceneIcon.CLEAN -> "🧹"
SceneIcon.CUSTOM -> "✨"
}
internal fun SceneColor.toColorOrNull(): Color? {
val cleanHex = hex.removePrefix("#")
return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull()
}
@@ -2,32 +2,48 @@ package com.flagship.abox.manager.ui.scene
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
enum class SceneFilterTab {
ALL,
MANUAL,
AUTOMATIC,
}
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class SceneListViewModel @Inject constructor(
private val sceneRepository: SceneRepository,
private val sceneExecutor: SceneExecutor,
private val profileStore: ProviderProfileStore,
) : ViewModel() {
val scenes: StateFlow<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 ->
if (profile == null) {
flowOf(DomainResult.Success(emptyList()))
@@ -42,9 +58,43 @@ class SceneListViewModel @Inject constructor(
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)
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) {
viewModelScope.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.rememberNavController
import androidx.navigation.navArgument
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.SceneId
/**
* Scene navigation routes. [SceneRoutes.LIST] is the entry point; the editor and execution routes
* carry the scene id as a path segment so deep linking works without manual argument parsing.
* Scene navigation routes.
*/
object SceneRoutes {
const val LIST = "scene/list"
const val EDITOR = "scene/editor"
const val EDITOR_MANUAL = "scene/editor/manual"
const val EDITOR_AUTOMATIC = "scene/editor/automatic"
const val EDITOR_WITH_ID = "scene/editor/{sceneId}"
const val EXECUTION = "scene/execution/{sceneId}"
@@ -39,7 +41,8 @@ fun SceneNavHost(
modifier = modifier,
) {
sceneListDestination(
onAddScene = { navController.navigate(SceneRoutes.EDITOR) },
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
)
@@ -56,14 +59,16 @@ fun SceneNavHost(
}
fun NavGraphBuilder.sceneListDestination(
onAddScene: () -> Unit,
onAddManual: () -> Unit,
onAddAutomatic: () -> Unit,
onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null,
) {
composable(SceneRoutes.LIST) {
SceneListScreen(
onAddScene = onAddScene,
onAddManual = onAddManual,
onAddAutomatic = onAddAutomatic,
onEditScene = onEditScene,
onExecuteScene = onExecuteScene,
headerContent = headerContent,
@@ -75,10 +80,14 @@ fun NavGraphBuilder.sceneEditorDestination(
onBack: () -> Unit,
onSaved: () -> Unit,
) {
composable(
route = SceneRoutes.EDITOR,
) {
SceneEditorScreen(sceneId = null, onBack = onBack, onSaved = onSaved)
composable(SceneRoutes.EDITOR) {
SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved)
}
composable(SceneRoutes.EDITOR_MANUAL) {
SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved)
}
composable(SceneRoutes.EDITOR_AUTOMATIC) {
SceneEditorScreen(sceneId = null, initialType = AutomationType.AUTOMATIC, onBack = onBack, onSaved = onSaved)
}
composable(
route = SceneRoutes.EDITOR_WITH_ID,
@@ -87,6 +96,7 @@ fun NavGraphBuilder.sceneEditorDestination(
val id = backStackEntry.arguments?.getString("sceneId")
SceneEditorScreen(
sceneId = id?.let { SceneId(it) },
initialType = null,
onBack = onBack,
onSaved = onSaved,
)
@@ -42,44 +42,124 @@
<string name="activity_placeholder_title">活动记录</string>
<string name="activity_placeholder_description">未记录任何事件。</string>
<!-- Scene: list -->
<string name="scene_list_title">情景模式</string>
<string name="scene_create_action">新建情景</string>
<!-- Scene & Automation strings -->
<string name="scene_list_title">自动化与情景</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_edit">编辑情景</string>
<string name="scene_delete">删除情景</string>
<string name="scene_empty_title">还没有情景</string>
<string name="scene_empty_message">新建第一个情景,组合插座和红外动作。</string>
<string name="scene_edit">编辑指令</string>
<string name="scene_delete">删除指令</string>
<string name="scene_empty_title">还没有自动化指令</string>
<string name="scene_empty_message">点击右下角按钮,建立一键指令或带触发器的自动指令。</string>
<string name="scene_action_run_now">立即执行</string>
<string name="scene_toast_executed">指令已触发执行</string>
<!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">删除情景</string>
<string name="scene_delete_dialog_message">此操作无法撤销。</string>
<string name="scene_delete_dialog_title">删除指令</string>
<string name="scene_delete_dialog_message">确定要删除该指令吗?此操作无法撤销。</string>
<string name="scene_delete_confirm">删除</string>
<string name="scene_delete_cancel">取消</string>
<!-- Scene: editor -->
<string name="scene_editor_new_title">新建情景</string>
<string name="scene_editor_edit_title">编辑情景</string>
<string name="scene_editor_manual_title">建立一键指令</string>
<string name="scene_editor_auto_title">建立自动指令</string>
<string name="scene_editor_edit_title">编辑指令</string>
<string name="scene_back">返回</string>
<string name="scene_name_label">名称</string>
<string name="scene_icon_label">图标</string>
<string name="scene_color_label">主题色(十六进制)</string>
<string name="scene_actions_label">动作(%1$d)</string>
<string name="scene_add_socket_action">添加插座动作</string>
<string name="scene_add_infrared_action">添加红外动作</string>
<string name="scene_device_name_label">设备名称</string>
<string name="scene_infrared_key_label">红外按键</string>
<string name="scene_name_label">标题</string>
<string name="scene_name_hint">请输入指令标题(如:回家一键开灯、高温自动降温)</string>
<string name="scene_description_label">描述</string>
<string name="scene_description_hint">可填写该指令的详细说明或备注</string>
<!-- Sections -->
<string name="scene_section_triggers">触发器</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_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>
<string name="scene_save_action">保存指令</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 -->
<string name="scene_execution_title">执行情景</string>
<string name="scene_execution_title">执行指令</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_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>
+105 -25
View File
@@ -42,44 +42,124 @@
<string name="activity_placeholder_title">Activity Log</string>
<string name="activity_placeholder_description">No recorded events yet.</string>
<!-- Scene: list -->
<string name="scene_list_title">Scenes</string>
<string name="scene_create_action">New scene</string>
<!-- Scene & Automation strings -->
<string name="scene_list_title">Automation &amp; Scenes</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_edit">Edit scene</string>
<string name="scene_delete">Delete scene</string>
<string name="scene_empty_title">No scenes yet</string>
<string name="scene_empty_message">Create your first scene to combine socket and infrared actions.</string>
<string name="scene_edit">Edit Command</string>
<string name="scene_delete">Delete Command</string>
<string name="scene_empty_title">No automation commands yet</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 -->
<string name="scene_delete_dialog_title">Delete scene</string>
<string name="scene_delete_dialog_message">This action cannot be undone.</string>
<string name="scene_delete_dialog_title">Delete Command</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_cancel">Cancel</string>
<!-- Scene: editor -->
<string name="scene_editor_new_title">New scene</string>
<string name="scene_editor_edit_title">Edit scene</string>
<string name="scene_editor_manual_title">Create Manual Command</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_name_label">Name</string>
<string name="scene_icon_label">Icon</string>
<string name="scene_color_label">Accent color (hex)</string>
<string name="scene_actions_label">Actions (%1$d)</string>
<string name="scene_add_socket_action">Add socket action</string>
<string name="scene_add_infrared_action">Add infrared action</string>
<string name="scene_device_name_label">Device name</string>
<string name="scene_infrared_key_label">Infrared key</string>
<string name="scene_name_label">Title</string>
<string name="scene_name_hint">Enter title (e.g. Movie Night, High Temp Auto Cooling)</string>
<string name="scene_description_label">Description</string>
<string name="scene_description_hint">Optional notes or details about this rule</string>
<!-- Sections -->
<string name="scene_section_triggers">Triggers</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_save_action">Save scene</string>
<string name="scene_action_socket">Socket %1$s -> %2$s</string>
<string name="scene_action_infrared">Infrared %1$s -> %2$s</string>
<string name="scene_save_action">Save Command</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 -->
<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_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_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 -->
<string name="device_list_title">Device List</string>
@@ -1,6 +1,10 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.ComparisonOperator
import com.flagship.abox.manager.domain.ConcurrencyMode
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ExecutionStrategy
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
@@ -8,12 +12,15 @@ import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneCondition
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneTrigger
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@@ -31,6 +38,7 @@ class SceneEditorViewModelTest {
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val virtualRemoteRepository = FakeVirtualRemoteRepository()
private val profileId = ProviderProfileId("local-experience")
private val existingScene = Scene(
@@ -58,8 +66,8 @@ class SceneEditorViewModelTest {
@Test
fun initializeWithNullPreparesNewScene() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(null)
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.MANUAL)
val state = viewModel.state.value
assertFalse(state.isEditing)
@@ -71,7 +79,7 @@ class SceneEditorViewModelTest {
@Test
fun initializeWithExistingSceneLoadsState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(existingScene.id)
advanceUntilIdle()
@@ -85,34 +93,60 @@ class SceneEditorViewModelTest {
@Test
fun addingAndReorderingActionsUpdatesState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(null)
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.MANUAL)
viewModel.updateName("My New Scene")
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
viewModel.addInfraredAction("ir-1", "power")
viewModel.addDelayAction(3)
viewModel.addNotificationAction("Done", "Completed")
advanceUntilIdle()
assertEquals(2, viewModel.state.value.actions.size)
assertEquals(3, viewModel.state.value.actions.size)
assertTrue(viewModel.state.value.actions[0] is SceneAction.SocketPower)
assertTrue(viewModel.state.value.actions[1] is SceneAction.InfraredKey)
assertTrue(viewModel.state.value.actions[1] is SceneAction.Delay)
assertTrue(viewModel.state.value.actions[2] is SceneAction.Notification)
// Move second to first
viewModel.moveAction(1, 0)
assertTrue(viewModel.state.value.actions[0] is SceneAction.InfraredKey)
assertTrue(viewModel.state.value.actions[0] is SceneAction.Delay)
assertTrue(viewModel.state.value.actions[1] is SceneAction.SocketPower)
// Remove first action
val firstId = viewModel.state.value.actions[0].id
viewModel.removeAction(firstId)
assertEquals(1, viewModel.state.value.actions.size)
assertEquals(2, viewModel.state.value.actions.size)
}
@Test
fun addingTriggersAndConditionsForAutomaticRule() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.AUTOMATIC)
viewModel.updateName("Auto Rule")
viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0)
viewModel.addSocketPowerCondition("socket-1", SocketPowerState.OFF)
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
viewModel.updateConcurrencyMode(ConcurrencyMode.PARALLEL)
viewModel.updateStrategy(ExecutionStrategy.ABORT_ON_ERROR)
advanceUntilIdle()
val state = viewModel.state.value
assertEquals(1, state.triggers.size)
assertTrue(state.triggers[0] is SceneTrigger.Temperature)
assertEquals(1, state.conditions.size)
assertTrue(state.conditions[0] is SceneCondition.SocketPower)
assertEquals(ConcurrencyMode.PARALLEL, state.concurrencyMode)
assertEquals(ExecutionStrategy.ABORT_ON_ERROR, state.strategy)
assertTrue(state.isValid)
}
@Test
fun saveNewScenePersistsToRepository() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(null)
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.MANUAL)
viewModel.updateName("Brand New Scene")
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
advanceUntilIdle()
@@ -125,3 +159,4 @@ class SceneEditorViewModelTest {
assertFalse(viewModel.state.value.isSaving)
}
}
@@ -1,5 +1,6 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
@@ -12,14 +13,18 @@ import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
@@ -33,12 +38,16 @@ class SceneListViewModelTest {
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val socketGateway = FakeSocketGateway()
private val infraredGateway = FakeInfraredGateway()
private val sceneExecutor = FakeSceneExecutor(socketGateway, infraredGateway)
private val profileId = ProviderProfileId("local-experience")
private val testScene = Scene(
private val manualScene = Scene(
id = SceneId("local-experience:scene-1"),
profileId = profileId,
name = "Test Scene",
name = "Manual Scene",
type = AutomationType.MANUAL,
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
@@ -50,39 +59,98 @@ class SceneListViewModelTest {
),
),
)
private val autoScene = Scene(
id = SceneId("local-experience:scene-2"),
profileId = profileId,
name = "Auto Rule",
type = AutomationType.AUTOMATIC,
enabled = true,
icon = SceneIcon.THERMOSTAT,
color = SceneColor("#26A69A"),
actions = listOf(
SceneAction.Delay(
id = SceneActionId("local-experience:action-2"),
profileId = profileId,
durationSeconds = 5,
),
),
)
@Test
fun loadsScenesForActiveProfile() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(testScene))
sceneRepository.setScenes(profileId, listOf(manualScene, autoScene))
val viewModel = SceneListViewModel(sceneRepository, profileStore)
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
assertEquals(2, viewModel.scenes.value.size)
assertEquals("Manual Scene", viewModel.scenes.value[0].name)
assertEquals("Auto Rule", viewModel.scenes.value[1].name)
}
@Test
fun filterTabFiltersScenes() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(manualScene, autoScene))
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.selectTab(SceneFilterTab.MANUAL)
advanceUntilIdle()
assertEquals(1, viewModel.scenes.value.size)
assertEquals("Test Scene", viewModel.scenes.value.first().name)
assertEquals(AutomationType.MANUAL, viewModel.scenes.value[0].type)
viewModel.selectTab(SceneFilterTab.AUTOMATIC)
advanceUntilIdle()
assertEquals(1, viewModel.scenes.value.size)
assertEquals(AutomationType.AUTOMATIC, viewModel.scenes.value[0].type)
}
@Test
fun toggleEnabledUpdatesSceneInRepository() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(autoScene))
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.toggleEnabled(autoScene)
advanceUntilIdle()
val updated = viewModel.scenes.value.first()
assertFalse(updated.enabled)
}
@Test
fun deleteSceneRemovesFromRepositoryAndClearsPending() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(testScene))
sceneRepository.setScenes(profileId, listOf(manualScene))
val viewModel = SceneListViewModel(sceneRepository, profileStore)
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.markPendingDelete(testScene.id)
assertEquals(testScene.id, viewModel.pendingDelete.value)
viewModel.markPendingDelete(manualScene.id)
assertEquals(manualScene.id, viewModel.pendingDelete.value)
viewModel.deleteScene(testScene.id)
viewModel.deleteScene(manualScene.id)
advanceUntilIdle()
assertNull(viewModel.pendingDelete.value)
@@ -91,11 +159,12 @@ class SceneListViewModelTest {
@Test
fun cancelDeleteClearsPendingState() = runTest {
val viewModel = SceneListViewModel(sceneRepository, profileStore)
viewModel.markPendingDelete(testScene.id)
assertEquals(testScene.id, viewModel.pendingDelete.value)
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.markPendingDelete(manualScene.id)
assertEquals(manualScene.id, viewModel.pendingDelete.value)
viewModel.cancelDelete()
assertNull(viewModel.pendingDelete.value)
}
}
@@ -1,20 +1,21 @@
package com.flagship.abox.manager.domain
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import kotlinx.coroutines.delay
import java.util.concurrent.ConcurrentHashMap
/**
* Production implementation of [SceneExecutor].
*
* Executes scene actions sequentially via [SocketGateway] and [InfraredGateway].
* Guarantees:
* 1. Actions are run sequentially in the order of [Scene.actions].
* 2. Individual action failures do not abort remaining actions; all actions run to completion.
* 3. Results are aggregated into a [SceneExecutionResult].
* 4. Re-entrant / concurrent execution of the same scene is rejected with [DomainError.OperationFailed].
* Executes scene actions via [SocketGateway], [InfraredGateway], [SendVirtualRemoteAction],
* and built-in timers.
*/
class DefaultSceneExecutor(
private val socketGateway: SocketGateway,
private val infraredGateway: InfraredGateway,
private val sendVirtualRemoteAction: SendVirtualRemoteAction? = null,
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
) : SceneExecutor {
private val runningScenes = ConcurrentHashMap<SceneId, Boolean>()
@@ -27,9 +28,41 @@ class DefaultSceneExecutor(
),
)
}
val startTime = System.currentTimeMillis()
val results = mutableListOf<SceneActionExecutionResult>()
var aborted = false
return try {
val results = scene.actions.map { action -> executeAction(scene.profileId, action) }
DomainResult.Success(SceneExecutionResult(scene = scene, results = results))
for (action in scene.actions) {
if (aborted) {
results.add(
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SKIPPED,
error = null,
),
)
continue
}
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enrichedResult = execResult.copy(executionDurationMillis = duration)
results.add(enrichedResult)
if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
aborted = true
}
}
val totalDuration = System.currentTimeMillis() - startTime
DomainResult.Success(
SceneExecutionResult(
scene = scene,
results = results,
totalDurationMillis = totalDuration,
),
)
} finally {
runningScenes.remove(scene.id)
}
@@ -39,30 +72,80 @@ class DefaultSceneExecutor(
profileId: ProviderProfileId,
action: SceneAction,
): SceneActionExecutionResult {
val result = when (action) {
is SceneAction.SocketPower -> socketGateway.setPower(
return when (action) {
is SceneAction.SocketPower -> {
val res = socketGateway.setPower(
profileId = profileId,
deviceName = action.deviceName,
powerState = action.powerState,
)
toExecutionResult(action, res)
}
is SceneAction.InfraredKey -> infraredGateway.sendKey(
is SceneAction.InfraredKey -> {
val res = infraredGateway.sendKey(
profileId = profileId,
deviceName = action.deviceName,
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(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
is DomainResult.Failure -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = result.error,
error = res.error,
)
}
}
@@ -96,36 +96,63 @@ class InMemorySceneRepository : SceneRepository {
Scene(
id = SceneId("${profileId.value}:scene-leaving-home"),
profileId = profileId,
name = "Leaving home",
name = "离家模式",
description = "关闭插座并延时关闭电视",
type = AutomationType.MANUAL,
enabled = true,
icon = SceneIcon.AWAY,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.OFF,
),
SceneAction.Delay(
id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"),
profileId = profileId,
durationSeconds = 1,
),
SceneAction.InfraredKey(
id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "power",
deviceName = DeviceName("红外控制器"),
key = "1",
),
),
),
Scene(
id = SceneId("${profileId.value}:scene-movie-night"),
id = SceneId("${profileId.value}:scene-cooling-auto"),
profileId = profileId,
name = "Movie night",
icon = SceneIcon.MOVIE,
color = SceneColor("#7986CB"),
name = "高温自动降温",
description = "当室温高于 28°C 时开启插座",
type = AutomationType.AUTOMATIC,
enabled = true,
icon = SceneIcon.HOME,
color = SceneColor("#26A69A"),
triggers = listOf(
SceneTrigger.Temperature(
id = SceneTriggerId("${profileId.value}:trigger-temp"),
deviceName = DeviceName("温湿度传感器"),
operator = ComparisonOperator.GREATER_THAN,
thresholdCelsius = 28.0,
label = "温度高于 28°C",
),
),
conditions = listOf(
SceneCondition.SocketPower(
id = SceneConditionId("${profileId.value}:cond-socket"),
deviceName = DeviceName("Zigbee插座控制器"),
expectedState = SocketPowerState.OFF,
),
),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-movie-night:socket-1"),
id = SceneActionId("${profileId.value}:scene-cooling:socket-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.ON,
),
),
),
@@ -6,51 +6,164 @@ value class SceneId(val value: String)
@JvmInline
value class SceneActionId(val value: String)
@JvmInline
value class SceneTriggerId(val value: String)
@JvmInline
value class SceneConditionId(val value: String)
enum class AutomationType {
MANUAL, // 一键指令 (One-Tap / Instant Command)
AUTOMATIC, // 自动指令 (Trigger & Condition Based Automation)
}
enum class SceneActionType {
SOCKET_POWER,
VIRTUAL_REMOTE_BUTTON,
INFRARED_KEY,
DELAY,
NOTIFICATION,
}
enum class ComparisonOperator(val symbol: String) {
GREATER_THAN(">"),
LESS_THAN("<"),
EQUALS("="),
GREATER_OR_EQUAL(">="),
LESS_OR_EQUAL("<="),
}
sealed interface SceneTrigger {
val id: SceneTriggerId
data class Time(
override val id: SceneTriggerId,
val timeOfDay: String, // e.g. "08:30"
val daysOfWeek: Set<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].
*
* Socket actions reference the [DeviceName] accepted by ABSDK 0630 and a target [SocketPowerState].
* Infrared actions reference a [DeviceName] and the stable key string provided by the infrared
* module; the executor delegates the actual send to [InfraredGateway.sendKey].
*
* `DeviceName` is used (rather than `DeviceId`) because ABSDK addresses devices by name, matching
* the existing [SocketGateway] and [InfraredGateway] contracts. The infrared module is responsible
* for keeping the key string stable across edits, so scenes can reference it without coupling to
* the infrared storage primary key.
*/
sealed interface SceneAction {
val id: SceneActionId
val profileId: ProviderProfileId
val deviceName: DeviceName
val type: SceneActionType
data class SocketPower(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
override val deviceName: DeviceName,
val deviceName: DeviceName,
val powerState: SocketPowerState,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.SOCKET_POWER
}
data class VirtualRemoteButton(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val remoteId: String,
val remoteDisplayName: String,
val buttonId: String,
val buttonDisplayName: String,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.VIRTUAL_REMOTE_BUTTON
}
data class InfraredKey(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
override val deviceName: DeviceName,
val deviceName: DeviceName,
val key: String,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.INFRARED_KEY
}
data class Delay(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val durationSeconds: Int,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.DELAY
}
data class Notification(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val title: String,
val message: String,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.NOTIFICATION
}
}
enum class ExecutionStrategy {
CONTINUE_ON_ERROR, // 遇错继续
ABORT_ON_ERROR, // 遇错中止
}
enum class ConcurrencyMode {
SEQUENTIAL, // 同步 / 串行
PARALLEL, // 异步 / 并行
}
/**
* Visual identity for a scene. Kept as a small enum so the UI can resolve the icon without
* external resources and so the persistence layer stays stable across icon set changes.
* Visual identity for a scene.
*/
enum class SceneIcon {
HOME,
@@ -58,12 +171,17 @@ enum class SceneIcon {
NIGHT,
MOVIE,
AWAY,
READING,
DINING,
GAMING,
THERMOSTAT,
LIGHT,
CLEAN,
CUSTOM,
}
/**
* Scene accent color stored as an uppercase hex string (e.g. `#FF8A65`). The UI maps it to the
* Compose color space; persistence stores it verbatim.
* Scene accent color stored as an uppercase hex string (e.g. `#3F51B5`).
*/
@JvmInline
value class SceneColor(val hex: String)
@@ -72,7 +190,15 @@ data class Scene(
val id: SceneId,
val profileId: ProviderProfileId,
val name: String,
val icon: SceneIcon,
val color: SceneColor,
val actions: List<SceneAction>,
val description: String = "",
val type: AutomationType = AutomationType.MANUAL,
val enabled: Boolean = true,
val icon: SceneIcon = SceneIcon.HOME,
val color: SceneColor = SceneColor("#3F51B5"),
val triggers: List<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 {
SUCCESS,
FAILURE,
SKIPPED,
}
/**
@@ -12,21 +13,24 @@ data class SceneActionExecutionResult(
val action: SceneAction,
val outcome: SceneActionOutcome,
val error: DomainError?,
val executionDurationMillis: Long = 0L,
)
/**
* Aggregated result of executing a [Scene]. Actions are executed sequentially in the order they
* appear in [scene.actions]; a per-action failure does not abort subsequent actions.
* Aggregated result of executing a [Scene].
*/
data class SceneExecutionResult(
val scene: Scene,
val results: List<SceneActionExecutionResult>,
val totalDurationMillis: Long = 0L,
) {
val totalActions: Int get() = results.size
val succeededCount: Int
get() = results.count { it.outcome == SceneActionOutcome.SUCCESS }
val failedCount: Int
get() = results.count { it.outcome == SceneActionOutcome.FAILURE }
val skippedCount: Int
get() = results.count { it.outcome == SceneActionOutcome.SKIPPED }
val allSucceeded: Boolean
get() = results.isNotEmpty() && failedCount == 0
get() = results.isNotEmpty() && failedCount == 0 && skippedCount == 0
}
@@ -149,4 +149,77 @@ class DefaultSceneExecutorTest {
assertEquals(1, socketGateway.calls.size)
assertEquals(1, infraredGateway.calls.size)
}
@Test
fun abortsExecutionOnFailureWhenStrategyIsAbortOnError() = runTest {
val socketGateway = TestSocketGateway(
response = DomainResult.Failure(DomainError.DeviceOffline("socket-1 unreachable")),
)
val infraredGateway = TestInfraredGateway(
response = DomainResult.Success(Unit),
)
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
)
val action2 = SceneAction.InfraredKey(
id = SceneActionId("a2"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "vol_up",
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Test Scene",
icon = SceneIcon.MOVIE,
color = SceneColor("#7986CB"),
actions = listOf(action1, action2),
strategy = ExecutionStrategy.ABORT_ON_ERROR,
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome)
assertEquals(0, infraredGateway.calls.size)
}
@Test
fun executesDelayActionSuccessfully() = runTest {
var delayCalled = 0L
val executor = DefaultSceneExecutor(
socketGateway = TestSocketGateway(),
infraredGateway = TestInfraredGateway(),
delayDurationProvider = { delayCalled = it },
)
val action = SceneAction.Delay(
id = SceneActionId("d1"),
profileId = profileId,
durationSeconds = 5,
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Delay Scene",
actions = listOf(action),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(1, executionResult.results.size)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome)
assertEquals(5000L, delayCalled)
}
}
@@ -181,12 +181,6 @@ class FakeSceneRepository : SceneRepository {
/**
* Fake [SceneExecutor] that drives the existing [SocketGateway] and [InfraredGateway] fakes.
*
* Behaviour matches the project_plan.md §4.7 contract:
* - actions run sequentially in [Scene.actions] order;
* - a single action failure does not abort subsequent actions;
* - the returned [SceneExecutionResult] contains one entry per action;
* - re-entering execute() while a scene is running returns [DomainError.OperationFailed].
*/
class FakeSceneExecutor(
private val socketGateway: SocketGateway,
@@ -203,9 +197,23 @@ class FakeSceneExecutor(
),
)
}
val startTime = System.currentTimeMillis()
val results = mutableListOf<SceneActionExecutionResult>()
return try {
val results = scene.actions.map { action -> executeAction(scene.profileId, action) }
DomainResult.Success(SceneExecutionResult(scene = scene, results = results))
for (action in scene.actions) {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
results.add(execResult.copy(executionDurationMillis = duration))
}
val totalDuration = System.currentTimeMillis() - startTime
DomainResult.Success(
SceneExecutionResult(
scene = scene,
results = results,
totalDurationMillis = totalDuration,
),
)
} finally {
running.remove(scene.id)
}
@@ -227,6 +235,10 @@ class FakeSceneExecutor(
deviceName = action.deviceName,
key = action.key,
)
is SceneAction.VirtualRemoteButton,
is SceneAction.Delay,
is SceneAction.Notification -> DomainResult.Success(Unit)
}
return when (result) {
is DomainResult.Success -> SceneActionExecutionResult(