feat(scene): refactor scene automation into modular trigger-action system

- Support manual one-tap commands and automatic condition-based rules
- Add modular triggers (time, temperature, humidity, device state)
- Add modular conditions (environment thresholds, socket state, time range)
- Upgrade actions to support virtual remotes, sockets, delays, and notifications
- Support sequential/parallel concurrency mode and error execution strategies
- Refactor SceneListScreen with filter tabs, quick execution, and enable switches
- Refactor SceneEditorScreen with modular builders and preset pickers
- Enhance SceneExecutionScreen with step timing, skips, and rerun capability
- Add comprehensive domain and UI ViewModel unit tests
This commit is contained in:
2026-08-25 16:15:31 +08:00
parent f70f034119
commit 9ac1616046
18 changed files with 2345 additions and 410 deletions
@@ -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(
sceneId = scene.id,
name = scene.name,
icon = scene.icon,
colorHex = scene.color.hex,
actions = scene.actions,
)
_state.update {
it.copy(
sceneId = scene.id,
type = scene.type,
name = scene.name,
description = scene.description,
enabled = scene.enabled,
icon = scene.icon,
colorHex = scene.color.hex,
triggers = scene.triggers,
conditions = scene.conditions,
actions = scene.actions,
strategy = scene.strategy,
concurrencyMode = scene.concurrencyMode,
enableLogging = scene.enableLogging,
)
}
} else {
_state.update { it.copy(errorMessage = "Scene not found") }
}
@@ -72,11 +129,104 @@ class SceneEditorViewModel @Inject constructor(
}
fun updateName(name: String) = _state.update { it.copy(name = name, errorMessage = null) }
fun updateDescription(desc: String) = _state.update { it.copy(description = desc) }
fun updateType(type: AutomationType) = _state.update { it.copy(type = type) }
fun updateEnabled(enabled: Boolean) = _state.update { it.copy(enabled = enabled) }
fun updateIcon(icon: SceneIcon) = _state.update { it.copy(icon = icon) }
fun updateColor(hex: String) = _state.update { it.copy(colorHex = hex) }
fun updateStrategy(strategy: ExecutionStrategy) = _state.update { it.copy(strategy = strategy) }
fun updateConcurrencyMode(mode: ConcurrencyMode) = _state.update { it.copy(concurrencyMode = mode) }
fun updateLogging(enabled: Boolean) = _state.update { it.copy(enableLogging = enabled) }
// --- Triggers ---
fun addTimeTrigger(timeOfDay: String, daysOfWeek: Set<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,57 +183,131 @@ private fun CompletedContent(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(
text = stringResource(
R.string.scene_execution_summary,
result.succeededCount,
result.failedCount,
result.totalActions,
item(key = "summary") {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (result.allSucceeded) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
} else {
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f)
},
),
style = MaterialTheme.typography.titleMedium,
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.scene_execution_done),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(
R.string.scene_execution_summary,
result.succeededCount,
result.failedCount,
result.totalActions,
),
style = MaterialTheme.typography.bodyMedium,
)
if (result.totalDurationMillis > 0) {
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.scene_execution_duration, result.totalDurationMillis),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
items(result.results, key = { it.action.id.value }) { entry ->
ActionResultRow(entry)
itemsIndexed(result.results, key = { index, _ -> "action_res_$index" }) { index, entry ->
ActionResultRow(index + 1, entry)
}
item {
Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.scene_execution_done))
item(key = "buttons") {
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = onRerun,
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("重新执行")
}
Button(
onClick = onBack,
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(12.dp),
) {
Text("返回", fontWeight = FontWeight.SemiBold)
}
}
}
}
}
@Composable
private fun ActionResultRow(entry: SceneActionExecutionResult) {
Card(modifier = Modifier.fillMaxWidth()) {
private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) {
Icons.Filled.Check
} else {
Icons.Filled.Close
Surface(
shape = CircleShape,
color = when (entry.outcome) {
SceneActionOutcome.SUCCESS -> MaterialTheme.colorScheme.primaryContainer
SceneActionOutcome.FAILURE -> MaterialTheme.colorScheme.errorContainer
SceneActionOutcome.SKIPPED -> MaterialTheme.colorScheme.surfaceVariant
},
contentDescription = null,
tint = if (entry.outcome == SceneActionOutcome.SUCCESS) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
},
)
Spacer(Modifier.size(12.dp))
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center) {
when (entry.outcome) {
SceneActionOutcome.SUCCESS -> Icon(
Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
SceneActionOutcome.FAILURE -> Icon(
Icons.Filled.Close,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
SceneActionOutcome.SKIPPED -> Icon(
Icons.Outlined.HourglassDisabled,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.action.describe(),
text = "步骤 $stepNumber: ${entry.action.describe()}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
entry.error?.detail?.let { detail ->
Spacer(Modifier.height(2.dp))
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
@@ -211,11 +315,22 @@ private fun ActionResultRow(entry: SceneActionExecutionResult) {
)
}
}
if (entry.executionDurationMillis > 0) {
Text(
text = "${entry.executionDurationMillis}ms",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
private fun SceneAction.describe(): String = when (this) {
is SceneAction.SocketPower -> "Socket ${deviceName.value} -> ${powerState.name}"
is SceneAction.InfraredKey -> "Infrared ${deviceName.value} -> $key"
is SceneAction.SocketPower -> "插座【${deviceName.value}】-> ${powerState.name}"
is SceneAction.VirtualRemoteButton -> "遥控【$remoteDisplayName】-> $buttonDisplayName"
is SceneAction.InfraredKey -> "红外【${deviceName.value}】-> 槽位 $key"
is SceneAction.Delay -> "⏱️ 等待 ${durationSeconds} 秒"
is SceneAction.Notification -> "🔔 提示: $title"
}
@@ -1,5 +1,6 @@
package com.flagship.abox.manager.ui.scene
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -10,60 +11,119 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SceneListScreen(
onAddScene: () -> Unit,
onAddManual: () -> Unit,
onAddAutomatic: () -> Unit,
onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null,
viewModel: SceneListViewModel = hiltViewModel(),
) {
val scenes by viewModel.scenes.collectAsStateWithLifecycle()
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
var showFabMenu by remember { mutableStateOf(false) }
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.userMessage.collectLatest { msg ->
if (msg == "executed") {
Toast.makeText(context, context.getString(R.string.scene_toast_executed), Toast.LENGTH_SHORT).show()
}
}
}
Scaffold(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onAddScene,
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text(stringResource(R.string.scene_create_action)) },
)
Box {
ExtendedFloatingActionButton(
onClick = { showFabMenu = true },
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text(stringResource(R.string.scene_create_action)) },
)
DropdownMenu(
expanded = showFabMenu,
onDismissRequest = { showFabMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.scene_create_manual)) },
leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) },
onClick = {
showFabMenu = false
onAddManual()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.scene_create_automatic)) },
leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) },
onClick = {
showFabMenu = false
onAddAutomatic()
},
)
}
}
},
) { innerPadding ->
LazyColumn(
@@ -71,7 +131,7 @@ fun SceneListScreen(
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
top = 12.dp,
bottom = innerPadding.calculateBottomPadding() + 80.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -81,15 +141,48 @@ fun SceneListScreen(
headerContent()
}
}
item(key = "tabs") {
SecondaryTabRow(
selectedTabIndex = selectedTab.ordinal,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
) {
SceneFilterTab.entries.forEach { tab ->
Tab(
selected = selectedTab == tab,
onClick = { viewModel.selectTab(tab) },
text = {
Text(
text = when (tab) {
SceneFilterTab.ALL -> stringResource(R.string.scene_tab_all)
SceneFilterTab.MANUAL -> stringResource(R.string.scene_tab_manual)
SceneFilterTab.AUTOMATIC -> stringResource(R.string.scene_tab_automatic)
},
fontWeight = if (selectedTab == tab) FontWeight.Bold else FontWeight.Normal,
)
},
)
}
}
}
if (scenes.isEmpty()) {
item(key = "empty") {
EmptySceneList(modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp))
}
} else {
items(scenes, key = { it.id.value }) { scene ->
SceneRow(
SceneCard(
scene = scene,
onClick = { onExecuteScene(scene.id) },
onClick = {
if (scene.type == AutomationType.MANUAL) {
onExecuteScene(scene.id)
} else {
onEditScene(scene.id)
}
},
onQuickExecute = { viewModel.executeQuick(scene) },
onToggleEnabled = { viewModel.toggleEnabled(scene) },
onEdit = { onEditScene(scene.id) },
onDelete = { viewModel.markPendingDelete(scene.id) },
)
@@ -105,7 +198,7 @@ fun SceneListScreen(
text = { Text(stringResource(R.string.scene_delete_dialog_message)) },
confirmButton = {
TextButton(onClick = { viewModel.deleteScene(sceneId) }) {
Text(stringResource(R.string.scene_delete_confirm))
Text(stringResource(R.string.scene_delete_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
@@ -118,58 +211,169 @@ fun SceneListScreen(
}
@Composable
private fun SceneRow(
private fun SceneCard(
scene: Scene,
onClick: () -> Unit,
onQuickExecute: () -> Unit,
onToggleEnabled: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
),
) {
Column(
modifier = Modifier
.clickable(onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.background(
color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary,
shape = CircleShape,
),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = scene.name,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(
R.string.scene_action_count,
scene.actions.size,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Box(
modifier = Modifier
.size(44.dp)
.background(
color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(12.dp),
),
contentAlignment = Alignment.Center,
) {
Text(
text = scene.icon.displayEmoji(),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = scene.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
TypeBadge(type = scene.type)
}
if (scene.description.isNotBlank()) {
Spacer(Modifier.height(2.dp))
Text(
text = scene.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (scene.type == AutomationType.AUTOMATIC) {
Switch(
checked = scene.enabled,
onCheckedChange = { onToggleEnabled() },
)
}
}
IconButton(onClick = onEdit) {
Icon(
Icons.Filled.Edit,
contentDescription = stringResource(R.string.scene_edit),
)
}
IconButton(onClick = onDelete) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.scene_delete),
)
Spacer(Modifier.height(10.dp))
// Trigger & Action summary pills
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (scene.type == AutomationType.AUTOMATIC && scene.triggers.isNotEmpty()) {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
) {
Text(
text = "⚡ ${scene.triggers.size} 触发器",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Text(
text = stringResource(R.string.scene_action_count, scene.actions.size),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (scene.type == AutomationType.MANUAL) {
FilledTonalButton(
onClick = onQuickExecute,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
modifier = Modifier.height(32.dp),
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium)
}
Spacer(Modifier.width(4.dp))
}
IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.scene_edit), modifier = Modifier.size(18.dp))
}
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.scene_delete), modifier = Modifier.size(18.dp))
}
}
}
}
}
}
@Composable
private fun TypeBadge(type: AutomationType) {
val (label, bg, fg) = when (type) {
AutomationType.MANUAL -> Triple(
stringResource(R.string.scene_tab_manual),
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.onPrimaryContainer,
)
AutomationType.AUTOMATIC -> Triple(
stringResource(R.string.scene_tab_automatic),
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.colorScheme.onSecondaryContainer,
)
}
Surface(
shape = RoundedCornerShape(4.dp),
color = bg,
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
style = MaterialTheme.typography.labelSmall,
color = fg,
)
}
}
@Composable
private fun EmptySceneList(modifier: Modifier = Modifier) {
Column(
@@ -179,12 +383,18 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "⚡",
style = MaterialTheme.typography.displayMedium,
)
Spacer(Modifier.size(8.dp))
Text(
text = stringResource(R.string.scene_empty_title),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.size(12.dp))
Spacer(Modifier.size(8.dp))
Text(
text = stringResource(R.string.scene_empty_message),
style = MaterialTheme.typography.bodyMedium,
@@ -194,8 +404,24 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
}
}
internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? {
fun SceneIcon.displayEmoji(): String = when (this) {
SceneIcon.HOME -> "🏠"
SceneIcon.OFFICE -> "💼"
SceneIcon.NIGHT -> "🌙"
SceneIcon.MOVIE -> "🎬"
SceneIcon.AWAY -> "🚶"
SceneIcon.READING -> "📖"
SceneIcon.DINING -> "🍽️"
SceneIcon.GAMING -> "🎮"
SceneIcon.THERMOSTAT -> "🌡️"
SceneIcon.LIGHT -> "💡"
SceneIcon.CLEAN -> "🧹"
SceneIcon.CUSTOM -> "✨"
}
internal fun SceneColor.toColorOrNull(): Color? {
val cleanHex = hex.removePrefix("#")
return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull()
}
@@ -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)
}
}